Everyone wants real-time features. Live notifications, chat, collaborative editing, live dashboards. WebSockets seem like the obvious choice.
But then you deploy to production with multiple servers and everything breaks.
The Scaling Problem
Client A connects to Server 1. Client B connects to Server 2. When A sends a message, B never gets it because theyre on different servers.
WebSocket vs SSE vs Polling
First, do you even need WebSockets?
| Method | Use Case | Complexity | |--------|----------|------------| | WebSocket | Chat, gaming, collaboration | High | | SSE | Notifications, live feeds | Medium | | Polling | Dashboards, status updates | Low |
Dont use WebSockets for one-way updates. SSE is simpler and works through proxies better.
Basic WebSocket Server (Node.js)
import { WebSocketServer, WebSocket } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
// Track connected clients
const clients = new Set<WebSocket>();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
// Broadcast to all clients
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
});
ws.on('close', () => {
clients.delete(ws);
});
});
This works on one server. Add a second server and messages dont cross over.
Scaling with Redis Pub/Sub
Every server subscribes to Redis. When a message comes in, publish to Redis, and all servers get it.
import { createClient } from 'redis';
import { WebSocketServer, WebSocket } from 'ws';
const publisher = createClient();
const subscriber = createClient();
await publisher.connect();
await subscriber.connect();
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set<WebSocket>();
// Subscribe to Redis channel
await subscriber.subscribe('chat', (message) => {
// Broadcast to all local clients
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('message', async (data) => {
// Publish to Redis (all servers receive it)
await publisher.publish('chat', data.toString());
});
ws.on('close', () => {
clients.delete(ws);
});
});
Room-Based Subscriptions
Most apps need rooms - chat rooms, document sessions, game lobbies:
const rooms = new Map<string, Set<WebSocket>>();
function joinRoom(ws: WebSocket, roomId: string) {
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add(ws);
}
function leaveRoom(ws: WebSocket, roomId: string) {
rooms.get(roomId)?.delete(ws);
if (rooms.get(roomId)?.size === 0) {
rooms.delete(roomId);
}
}
function broadcastToRoom(roomId: string, message: string) {
rooms.get(roomId)?.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
// With Redis, subscribe per room
await subscriber.subscribe(`room:${roomId}`, (message) => {
broadcastToRoom(roomId, message);
});
Handling Connection State
// Heartbeat to detect dead connections
const HEARTBEAT_INTERVAL = 30000;
wss.on('connection', (ws) => {
let isAlive = true;
ws.on('pong', () => {
isAlive = true;
});
const interval = setInterval(() => {
if (!isAlive) {
ws.terminate();
return;
}
isAlive = false;
ws.ping();
}, HEARTBEAT_INTERVAL);
ws.on('close', () => {
clearInterval(interval);
});
});
Client-Side Reconnection
Connections drop. Handle it gracefully:
class WebSocketClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
connect(url: string) {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
console.log('Connected');
};
this.ws.onclose = () => {
this.scheduleReconnect(url);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
private scheduleReconnect(url: string) {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}
// Exponential backoff
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => this.connect(url), delay);
}
}
Message Protocol
Dont just send raw strings. Use a structured protocol:
interface WSMessage {
type: 'chat' | 'presence' | 'typing' | 'error';
payload: unknown;
timestamp: number;
id: string;
}
// Server
ws.on('message', (data) => {
const message: WSMessage = JSON.parse(data.toString());
switch (message.type) {
case 'chat':
handleChatMessage(message.payload);
break;
case 'typing':
broadcastTypingIndicator(message.payload);
break;
case 'presence':
updateUserPresence(message.payload);
break;
}
});
// Client
function sendMessage(type: WSMessage['type'], payload: unknown) {
ws.send(JSON.stringify({
type,
payload,
timestamp: Date.now(),
id: crypto.randomUUID()
}));
}
Authentication
Authenticate on connection, not per message:
import { verify } from 'jsonwebtoken';
wss.on('connection', (ws, req) => {
// Get token from query string or header
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token');
try {
const user = verify(token, process.env.JWT_SECRET);
ws.userId = user.id;
// Now this connection is authenticated
clients.set(user.id, ws);
} catch (error) {
ws.close(4001, 'Unauthorized');
}
});
Production Checklist
Rate limiting:
const messageCount = new Map<string, number>();
ws.on('message', (data) => {
const count = messageCount.get(ws.userId) || 0;
if (count > 100) { // 100 messages per minute
ws.send(JSON.stringify({ type: 'error', message: 'Rate limited' }));
return;
}
messageCount.set(ws.userId, count + 1);
// Process message...
});
// Reset counts every minute
setInterval(() => messageCount.clear(), 60000);
Further Reading
- Socket.io - Higher-level WebSocket library with fallbacks
- ws npm package - Fast, production WebSocket implementation
- Redis Pub/Sub docs
WebSockets are powerful but complex. Start with SSE if you only need server-to-client updates. When you do need WebSockets, plan for scale from day one - retrofitting Redis pub/sub into an existing system is painful.
