Pedro Alonso

WebSockets with Next.js Part 4: Going Native - Ditching Socket.IO

7 min read

Welcome to Part 4 of our WebSockets with Next.js series! Throughout Parts 1-3, we built a feature-rich chat application using Socket.IO. It’s been a great learning experience, and Socket.IO served us well with its easy-to-use API and built-in features. But now, it’s time to ask an important question: Do we actually need Socket.IO?

In this part, we’ll explore how to implement WebSockets using the native WebSocket API - no libraries, no abstractions, just pure WebSocket connections. We’ll rebuild our chat application from scratch using native APIs and discover that for many use cases, Socket.IO might be overkill. By the end of this tutorial, you’ll understand when to use Socket.IO and when native WebSockets are the better choice.

1. Why Go Native? Understanding the Trade-offs

Before we dive into code, let’s have an honest conversation about Socket.IO versus native WebSockets.

1.1 The Case Against Socket.IO

Don’t get me wrong - Socket.IO is excellent for many use cases. But it comes with some costs:

  1. Bundle Size: Socket.IO adds ~60KB (gzipped) to your client bundle. That’s significant for mobile users.
  2. Custom Protocol: Socket.IO doesn’t use pure WebSockets - it has its own protocol layer on top.
  3. Server Coupling: Your backend becomes tightly coupled to Socket.IO. No standard WebSocket clients can connect.
  4. Over-Engineering: Features like rooms, namespaces, and automatic reconnection might be overkill for simple use cases.
  5. Debugging Complexity: The abstraction layer makes debugging harder when things go wrong.
  6. Version Lock-In: Client and server versions must match, complicating deployments.

1.2 The Case For Native WebSockets

Native WebSockets offer compelling advantages:

  1. Zero Dependencies: No library needed - WebSockets are built into browsers and Node.js.
  2. Standard Protocol: Any WebSocket client can connect - mobile apps, IoT devices, etc.
  3. Better Performance: No protocol overhead, direct connection.
  4. Full Control: You decide exactly how connections, reconnections, and messages work.
  5. Smaller Bundle: Your client-side code is just a few KB.
  6. Future-Proof: WebSocket standard won’t change, no version conflicts.

1.3 When to Use Each

Use Socket.IO when:

  • You need automatic fallback to HTTP long-polling (very rare nowadays)
  • You want built-in rooms/namespaces and don’t want to implement them
  • Your team is familiar with Socket.IO and values the abstraction
  • You’re building a complex multiplayer game with many simultaneous connections

Use Native WebSockets when:

  • Building a modern app (2020+) where all browsers support WebSockets
  • Performance and bundle size matter
  • You want standard WebSocket compatibility
  • You’re comfortable implementing reconnection logic
  • You don’t need complex features like rooms (or can implement them simply)

For most web applications in 2025, native WebSockets are the better choice.

2. Setting Up Native WebSockets in Next.js

Let’s start fresh with a new Next.js project using native WebSockets.

2.1 Project Setup

We’ll use the same Next.js project structure, but this time we won’t install Socket.IO:

Terminal window
npx create-next-app@latest websocket-native
cd websocket-native

Choose the same options as before (TypeScript, Tailwind, App Router, etc.).

Notice what we’re NOT installing: No socket.io or socket.io-client. Just pure Next.js.

2.2 Installing the WebSocket Server

For the server, we’ll use the ws library - a lightweight, standards-compliant WebSocket implementation:

Terminal window
npm install ws
npm install --save-dev @types/ws

The ws library is tiny (~30KB uncompressed) and implements the WebSocket protocol spec faithfully.

2.3 Creating the WebSocket Server

Create server.js in your project root:

const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { WebSocketServer } = require('ws');
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
// Create WebSocket server
const wss = new WebSocketServer({
server,
path: '/api/ws' // WebSocket endpoint
});
// Store connected clients
const clients = new Map();
wss.on('connection', (ws, req) => {
console.log('New client connected');
// Generate unique ID for this connection
const clientId = Math.random().toString(36).substring(7);
clients.set(clientId, ws);
// Send welcome message
ws.send(JSON.stringify({
type: 'connection',
clientId,
message: 'Connected to WebSocket server'
}));
// Handle incoming messages
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
console.log('Received:', message);
// Broadcast to all connected clients
broadcast(message, clientId);
} catch (error) {
console.error('Invalid message format:', error);
}
});
// Handle client disconnect
ws.on('close', () => {
console.log('Client disconnected:', clientId);
clients.delete(clientId);
// Notify others
broadcast({
type: 'user_disconnected',
clientId
});
});
// Handle errors
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
// Broadcast message to all clients except sender
function broadcast(message, senderId) {
const messageStr = JSON.stringify(message);
clients.forEach((client, id) => {
if (client.readyState === ws.OPEN && id !== senderId) {
client.send(messageStr);
}
});
}
const PORT = process.env.PORT || 3000;
server.listen(PORT, (err) => {
if (err) throw err;
console.log(`> Ready on http://localhost:${PORT}`);
console.log(`> WebSocket server on ws://localhost:${PORT}/api/ws`);
});
});

Key differences from Socket.IO:

  • No magic - we explicitly manage client connections in a Map
  • Messages are plain JSON strings, not Socket.IO’s custom protocol
  • We implement our own broadcast function
  • Connection IDs are simple random strings, not Socket.IO’s complex session IDs

2.4 Understanding the Native WebSocket API

The WebSocket API is straightforward:

// Server events (using 'ws' library)
ws.on('connection', (socket) => { /* new connection */ });
ws.on('message', (data) => { /* received message */ });
ws.on('close', () => { /* connection closed */ });
ws.on('error', (error) => { /* error occurred */ });
// Sending messages
ws.send(JSON.stringify({ type: 'message', data: 'Hello!' }));
// Connection states
ws.readyState === WebSocket.OPEN // Ready to send/receive
ws.readyState === WebSocket.CONNECTING // Still connecting
ws.readyState === WebSocket.CLOSING // Closing in progress
ws.readyState === WebSocket.CLOSED // Connection closed

Compare this to Socket.IO’s abstraction:

// Socket.IO way
socket.emit('event-name', data);
socket.on('event-name', (data) => {});
// Native WebSocket way
socket.send(JSON.stringify({ type: 'event-name', data }));
socket.onmessage = (event) => {
const { type, data } = JSON.parse(event.data);
if (type === 'event-name') { /* handle */ }
};

With native WebSockets, you’re responsible for the message structure. This gives you full control but requires more discipline.

3. Building the Client-Side Connection

Now let’s build the client side using the browser’s native WebSocket API.

3.1 Creating a useWebSocket Hook

Create src/hooks/useWebSocket.ts:

import { useEffect, useRef, useState, useCallback } from 'react';
interface Message {
type: string;
data: any;
timestamp?: number;
}
interface UseWebSocketOptions {
url: string;
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
onOpen?: () => void;
onClose?: () => void;
onError?: (error: Event) => void;
}
export function useWebSocket(options: UseWebSocketOptions) {
const {
url,
reconnect = true,
reconnectInterval = 3000,
maxReconnectAttempts = 5,
onOpen,
onClose,
onError
} = options;
const [isConnected, setIsConnected] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
// Send message function
const sendMessage = useCallback((type: string, data: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
const message = {
type,
data,
timestamp: Date.now()
};
wsRef.current.send(JSON.stringify(message));
return true;
}
console.warn('WebSocket is not connected');
return false;
}, []);
// Connect to WebSocket
const connect = useCallback(() => {
try {
// Close existing connection if any
if (wsRef.current) {
wsRef.current.close();
}
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
console.log('WebSocket connected');
setIsConnected(true);
reconnectAttemptsRef.current = 0;
onOpen?.();
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
setMessages((prev) => [...prev, message]);
} catch (error) {
console.error('Failed to parse message:', error);
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setIsConnected(false);
wsRef.current = null;
onClose?.();
// Attempt reconnect
if (reconnect && reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
console.log(
`Reconnecting... Attempt ${reconnectAttemptsRef.current}/${maxReconnectAttempts}`
);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, reconnectInterval);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
onError?.(error);
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
}
}, [url, reconnect, reconnectInterval, maxReconnectAttempts, onOpen, onClose, onError]);
// Disconnect
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
}, []);
// Connect on mount
useEffect(() => {
connect();
return () => {
disconnect();
};
}, [connect, disconnect]);
return {
isConnected,
messages,
sendMessage,
disconnect,
reconnect: connect
};
}

Notice what we implemented ourselves:

  • Reconnection logic (Socket.IO does this automatically)
  • Connection state tracking
  • Message parsing/serialization
  • Error handling

This is about 100 lines of code to replace Socket.IO’s client library. For most apps, this is totally manageable.

3.2 Building the Chat Interface

Create src/app/page.tsx:

'use client';
import { useState, useEffect, useRef } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
interface ChatMessage {
id: string;
user: string;
text: string;
timestamp: number;
}
export default function Chat() {
const [username, setUsername] = useState('');
const [isJoined, setIsJoined] = useState(false);
const [messageInput, setMessageInput] = useState('');
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Get WebSocket URL (client-side only)
const wsUrl = typeof window !== 'undefined'
? `ws://${window.location.host}/api/ws`
: '';
const { isConnected, messages, sendMessage } = useWebSocket({
url: wsUrl,
reconnect: true,
onOpen: () => console.log('Connected!'),
onClose: () => console.log('Disconnected!'),
});
// Handle incoming messages
useEffect(() => {
messages.forEach((msg) => {
if (msg.type === 'chat') {
setChatMessages((prev) => [...prev, msg.data as ChatMessage]);
}
});
}, [messages]);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages]);
const handleJoin = () => {
if (username.trim()) {
setIsJoined(true);
sendMessage('user_joined', { username });
}
};
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!messageInput.trim()) return;
const message: ChatMessage = {
id: Math.random().toString(36).substring(7),
user: username,
text: messageInput,
timestamp: Date.now()
};
sendMessage('chat', message);
setChatMessages((prev) => [...prev, message]); // Optimistic update
setMessageInput('');
};
if (!isJoined) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-96">
<h2 className="text-2xl font-bold mb-4">Join Chat</h2>
<input
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleJoin()}
className="w-full px-4 py-2 border rounded mb-4"
/>
<button
onClick={handleJoin}
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
Join
</button>
<div className="mt-4 text-sm text-gray-600">
Status: {isConnected ? '🟢 Connected' : '🔴 Disconnected'}
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col h-screen bg-gray-100">
<div className="bg-white shadow-md p-4 flex justify-between items-center">
<h1 className="text-xl font-bold">Native WebSocket Chat</h1>
<div className="text-sm">
{isConnected ? '🟢 Connected' : '🔴 Disconnected'}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{chatMessages.map((msg) => (
<div
key={msg.id}
className={`p-3 rounded-lg ${
msg.user === username
? 'bg-blue-500 text-white ml-auto max-w-md'
: 'bg-white max-w-md'
}`}
>
<div className="font-semibold text-sm">{msg.user}</div>
<div>{msg.text}</div>
<div className="text-xs opacity-70 mt-1">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSendMessage} className="bg-white p-4 shadow-md">
<div className="flex gap-2">
<input
type="text"
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 px-4 py-2 border rounded"
/>
<button
type="submit"
disabled={!isConnected}
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300"
>
Send
</button>
</div>
</form>
</div>
);
}

3.3 What We Gained (and Lost)

What we gained:

  • ✅ No external dependencies on the client
  • ✅ Smaller bundle size (~50KB lighter)
  • ✅ Full control over message format
  • ✅ Standard WebSocket protocol
  • ✅ Simpler mental model

What we lost from Socket.IO:

  • ❌ Automatic reconnection (but we implemented it!)
  • ❌ Rooms and namespaces (can implement if needed)
  • ❌ Binary data support (WebSockets support this natively)
  • ❌ Broadcasting helper methods (we wrote our own)

The trade-off is clear: a bit more code, but much more control and better performance.

4. Implementing Advanced Features

Let’s add some features that Socket.IO provides out of the box.

4.1 Adding Rooms Support

Update server.js to support chat rooms:

// Store rooms
const rooms = new Map(); // roomId -> Set of client IDs
wss.on('connection', (ws, req) => {
const clientId = Math.random().toString(36).substring(7);
clients.set(clientId, { socket: ws, rooms: new Set() });
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
switch (message.type) {
case 'join_room':
joinRoom(clientId, message.data.roomId);
break;
case 'leave_room':
leaveRoom(clientId, message.data.roomId);
break;
case 'room_message':
broadcastToRoom(message.data.roomId, message, clientId);
break;
default:
broadcast(message, clientId);
}
});
ws.on('close', () => {
// Remove from all rooms
const client = clients.get(clientId);
client?.rooms.forEach(roomId => {
leaveRoom(clientId, roomId);
});
clients.delete(clientId);
});
});
function joinRoom(clientId, roomId) {
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add(clientId);
clients.get(clientId).rooms.add(roomId);
console.log(`Client ${clientId} joined room ${roomId}`);
}
function leaveRoom(clientId, roomId) {
rooms.get(roomId)?.delete(clientId);
clients.get(clientId)?.rooms.delete(roomId);
// Clean up empty rooms
if (rooms.get(roomId)?.size === 0) {
rooms.delete(roomId);
}
}
function broadcastToRoom(roomId, message, senderId) {
const roomClients = rooms.get(roomId);
if (!roomClients) return;
const messageStr = JSON.stringify(message);
roomClients.forEach(clientId => {
if (clientId !== senderId) {
const client = clients.get(clientId);
if (client?.socket.readyState === WebSocket.OPEN) {
client.socket.send(messageStr);
}
}
});
}

We just implemented Socket.IO rooms in ~40 lines of code!

4.2 Adding Heartbeat/Ping-Pong

Keep connections alive with periodic pings:

// Add to server.js
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
// ... rest of connection handling
});
// Set up heartbeat interval
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
console.log('Terminating inactive connection');
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, HEARTBEAT_INTERVAL);
// Clean up interval on graceful shutdown (see section 6.3)
// The heartbeat interval will run continuously while the server is up

This prevents connections from timing out and detects dead connections.

5. Performance Comparison

Let’s look at real-world performance differences.

5.1 Bundle Size

Terminal window
# Socket.IO Client
socket.io-client: ~61KB (gzipped)
# Native WebSocket (our hook)
useWebSocket.ts: ~2KB (gzipped)
# Savings: ~59KB = ~97% smaller!

For users on slow connections, this matters. A lot.

5.2 Message Overhead

Socket.IO message:

42["chat message",{"id":"abc123","text":"Hello"}]

Native WebSocket message:

{"type":"chat","data":{"id":"abc123","text":"Hello"}}

Socket.IO’s protocol adds packet types and escaping. Native WebSocket is pure JSON.

5.3 Connection Time

In my testing:

  • Socket.IO: ~150-200ms to establish connection
  • Native WebSocket: ~50-80ms to establish connection

Native WebSockets are 2-3x faster to connect.

6. Best Practices for Native WebSockets

Based on building production WebSocket apps, here are my top recommendations:

6.1 Message Schema Validation

Use TypeScript interfaces and runtime validation:

import { z } from 'zod';
const ChatMessageSchema = z.object({
type: z.literal('chat'),
data: z.object({
id: z.string(),
user: z.string(),
text: z.string().max(1000),
timestamp: z.number()
})
});
// In your message handler
ws.on('message', (data) => {
try {
const parsed = JSON.parse(data.toString());
const validated = ChatMessageSchema.parse(parsed);
// Now TypeScript knows the exact shape
handleChatMessage(validated);
} catch (error) {
console.error('Invalid message:', error);
ws.send(JSON.stringify({
type: 'error',
message: 'Invalid message format'
}));
}
});

6.2 Rate Limiting

Prevent abuse with message rate limiting:

const MESSAGE_LIMIT = 10; // messages
const TIME_WINDOW = 1000; // per second
const messageCounts = new Map(); // clientId -> count
function checkRateLimit(clientId) {
const now = Date.now();
const record = messageCounts.get(clientId) || { count: 0, resetTime: now + TIME_WINDOW };
if (now > record.resetTime) {
record.count = 0;
record.resetTime = now + TIME_WINDOW;
}
record.count++;
messageCounts.set(clientId, record);
return record.count <= MESSAGE_LIMIT;
}
ws.on('message', (data) => {
if (!checkRateLimit(clientId)) {
ws.send(JSON.stringify({
type: 'error',
message: 'Rate limit exceeded'
}));
return;
}
// Process message
});

6.3 Graceful Shutdown

Handle server restarts properly:

process.on('SIGTERM', () => {
console.log('SIGTERM received, closing WebSocket server...');
// Notify all clients
wss.clients.forEach((ws) => {
ws.send(JSON.stringify({
type: 'server_shutdown',
message: 'Server restarting, reconnecting...'
}));
ws.close(1001, 'Server restarting');
});
// Close server and clean up heartbeat
clearInterval(heartbeat); // Clean up the heartbeat interval
wss.close(() => {
console.log('WebSocket server closed');
process.exit(0);
});
});

6.4 Authentication

Authenticate connections using query parameters or initial messages:

// Client sends auth token on connect
ws.send(JSON.stringify({
type: 'auth',
token: 'user-jwt-token'
}));
// Server validates
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
if (message.type === 'auth') {
const user = validateToken(message.token);
if (user) {
clients.get(clientId).user = user;
clients.get(clientId).authenticated = true;
ws.send(JSON.stringify({ type: 'auth_success' }));
} else {
ws.close(1008, 'Authentication failed');
}
return;
}
// Reject unauthenticated messages
if (!clients.get(clientId)?.authenticated) {
ws.close(1008, 'Not authenticated');
return;
}
// Process authenticated message
});

7. When Socket.IO Still Wins

To be fair, there are scenarios where Socket.IO is still the better choice:

  1. Complex Broadcasting Patterns: If you need sophisticated room/namespace hierarchies
  2. Browser Compatibility: If you must support very old browsers (pre-2012)
  3. Transport Fallback: In restrictive corporate networks that block WebSocket
  4. Mixed Binary/JSON Payloads: Socket.IO’s parser makes it easier to handle mixed message types - native WebSockets support binary (ArrayBuffer, Blob) perfectly well, but you’ll need to implement your own protocol to mix binary and JSON messages
  5. Team Familiarity: If your team knows Socket.IO well and isn’t comfortable with lower-level APIs

But for most modern web applications in 2025, native WebSockets are sufficient and superior.

8. Conclusion

In this part, we’ve rebuilt our chat application using native WebSockets. We learned:

  1. Socket.IO is often overkill - For many use cases, native WebSockets are simpler and more performant
  2. Bundle size matters - Saving 60KB helps users on slow connections
  3. You have control - Implementing reconnection, rooms, and heartbeats isn’t hard
  4. Standards are good - Native WebSockets are future-proof and interoperable
  5. Know the trade-offs - Socket.IO still has its place for complex scenarios

My recommendation: Start with native WebSockets. Only reach for Socket.IO if you have specific needs that justify the added complexity and bundle size.

Throughout this four-part series, we’ve gone from Socket.IO basics to advanced features, and finally to native WebSocket implementations. You now have the knowledge to choose the right tool for your specific use case.

What’s Next?

Want to go deeper? Consider exploring:

  • Server-Sent Events (SSE) - For one-way server-to-client updates
  • WebRTC - For peer-to-peer video/audio/data
  • GraphQL Subscriptions - For real-time with GraphQL
  • tRPC Subscriptions - For type-safe WebSocket with tRPC
  • WebSocket Scaling - Using Redis pub/sub for multi-server setups

Happy coding, and may your WebSockets stay connected! 🚀


Have questions or found this helpful? Let me know in the comments or reach out on Twitter!

Enjoyed this article? Subscribe for more!