WebSockets in Node.js: Real-Time Features Without the Complexity Tax
Learn when SSE is enough and when WebSockets make sense, with production-ready Node.js patterns for auth, rooms, Redis scaling, reconnection, and Nginx proxying.
Senior Developer

Most real-time features do not need the full complexity of a dedicated WebSocket framework. A notification badge that updates when a new message arrives, a live order status page, a collaborative cursor — these are solved problems with well-understood patterns. The complexity comes from doing them wrong: broadcasting to everyone instead of specific users, not handling disconnections, scaling without a shared message bus, and forgetting that WebSocket connections survive across deploys.
This guide covers the production implementation: authenticated connections, rooms, Redis-backed pub/sub for multi-server scaling, reconnection handling, and the decision between WebSockets and SSE.
WebSockets vs Server-Sent Events
Before picking WebSockets, ask if you actually need bidirectional communication.
Server-Sent Events (SSE) — unidirectional, server-to-client only. Built into browsers natively (EventSource), works over HTTP/1.1, automatic reconnection, proxy-friendly. Perfect for: notifications, live feeds, order status updates, dashboards.
WebSockets — bidirectional, full-duplex. Better for: chat, collaborative editing, live cursors, multiplayer games, anything where the client sends frequent messages.
If your use case is "server pushes updates to the client," start with SSE. It is simpler, more proxy-friendly, and works over regular HTTP without upgrade negotiation.
This guide covers both, starting with SSE since it solves more use cases with less complexity.
Server-Sent Events Implementation
npm install express// src/routes/sse.ts
import { Request, Response } from 'express';
import { sseManager } from '../lib/sseManager';
router.get('/events', authenticate, (req, res) => {
// SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
// Required for proxies that buffer responses
'X-Accel-Buffering': 'no',
});
// Send a comment every 30s to keep the connection alive through proxies
const keepAlive = setInterval(() => {
res.write(': ping\n\n');
}, 30_000);
// Register this client
const clientId = sseManager.addClient(req.user.id, res);
// Send initial connection confirmation
res.write(`event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`);
// Clean up on disconnect
req.on('close', () => {
clearInterval(keepAlive);
sseManager.removeClient(clientId);
});
});// src/lib/sseManager.ts
import { Response } from 'express';
import { randomUUID } from 'crypto';
interface SSEClient {
id: string;
userId: string;
res: Response;
}
class SSEManager {
private clients = new Map<string, SSEClient>();
addClient(userId: string, res: Response): string {
const id = randomUUID();
this.clients.set(id, { id, userId, res });
return id;
}
removeClient(clientId: string): void {
this.clients.delete(clientId);
}
// Send to a specific user (all their open tabs/devices)
sendToUser(userId: string, event: string, data: unknown): void {
for (const client of this.clients.values()) {
if (client.userId === userId) {
this.send(client.res, event, data);
}
}
}
// Send to all connected clients
broadcast(event: string, data: unknown): void {
for (const client of this.clients.values()) {
this.send(client.res, event, data);
}
}
private send(res: Response, event: string, data: unknown): void {
try {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
// Client disconnected — will be cleaned up by 'close' event
}
}
get connectedCount(): number {
return this.clients.size;
}
}
export const sseManager = new SSEManager();Send an event from anywhere in your app:
// After creating an order — notify the user
await createOrder(data);
sseManager.sendToUser(req.user.id, 'order:created', {
orderId: order.id,
status: order.status,
total: order.total,
});On the client:
const events = new EventSource('/api/events', {
withCredentials: true,
});
events.addEventListener('order:created', (e) => {
const order = JSON.parse(e.data);
updateOrderBadge(order);
});
events.addEventListener('connected', (e) => {
console.log('SSE connected:', JSON.parse(e.data));
});
// EventSource reconnects automatically on disconnect
events.onerror = (err) => {
console.error('SSE error:', err);
};WebSockets with ws
For bidirectional communication, ws is the minimal, well-maintained WebSocket library for Node.js. socket.io adds reconnection, rooms, and fallbacks but at the cost of a client-side dependency and a heavier protocol.
npm install ws
npm install -D @types/ws// src/lib/websocketServer.ts
import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage } from 'http';
import { verifyAccessToken } from './tokens';
import { parse as parseCookie } from 'cookie';
interface AuthenticatedWebSocket extends WebSocket {
userId: string;
tenantId: string;
rooms: Set<string>;
isAlive: boolean;
}
export function createWebSocketServer(server: http.Server) {
const wss = new WebSocketServer({ server, path: '/ws' });
// Ping/pong heartbeat — detect dead connections
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
const client = ws as AuthenticatedWebSocket;
if (!client.isAlive) {
client.terminate();
return;
}
client.isAlive = false;
client.ping();
});
}, 30_000);
wss.on('close', () => clearInterval(heartbeat));
wss.on('connection', async (ws: AuthenticatedWebSocket, req: IncomingMessage) => {
// Authenticate the connection
const token = extractToken(req);
if (!token) {
ws.close(1008, 'Unauthorized');
return;
}
try {
const payload = verifyAccessToken(token);
ws.userId = payload.sub;
ws.tenantId = payload.tenantId;
ws.rooms = new Set();
ws.isAlive = true;
} catch {
ws.close(1008, 'Invalid token');
return;
}
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleMessage(ws, message, wss);
} catch {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
}
});
ws.on('close', () => {
wsManager.removeClient(ws);
});
ws.on('error', (err) => {
logger.error({ userId: ws.userId, error: err.message }, 'WebSocket error');
});
// Register the client
wsManager.addClient(ws);
// Send connection confirmation
send(ws, 'connected', { userId: ws.userId });
});
return wss;
}
function extractToken(req: IncomingMessage): string | null {
// Try Authorization header first
const authHeader = req.headers['authorization'];
if (authHeader?.startsWith('Bearer ')) {
return authHeader.split(' ')[1];
}
// Fall back to cookie (for browser clients)
const cookies = parseCookie(req.headers.cookie || '');
return cookies.wsToken || null;
}
function handleMessage(
ws: AuthenticatedWebSocket,
message: { type: string; payload?: unknown },
wss: WebSocketServer
) {
switch (message.type) {
case 'join:room': {
const { roomId } = message.payload as { roomId: string };
// Validate the user has access to this room
ws.rooms.add(roomId);
send(ws, 'room:joined', { roomId });
break;
}
case 'leave:room': {
const { roomId } = message.payload as { roomId: string };
ws.rooms.delete(roomId);
break;
}
case 'ping': {
send(ws, 'pong', { timestamp: Date.now() });
break;
}
default:
send(ws, 'error', { message: `Unknown message type: ${message.type}` });
}
}
function send(ws: WebSocket, type: string, payload: unknown): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, payload }));
}
}WebSocket Manager
// src/lib/wsManager.ts
import { WebSocket } from 'ws';
interface ManagedClient extends WebSocket {
userId: string;
tenantId: string;
rooms: Set<string>;
}
class WebSocketManager {
// userId → Set of connections (multiple tabs/devices)
private userClients = new Map<string, Set<ManagedClient>>();
// roomId → Set of connections
private roomClients = new Map<string, Set<ManagedClient>>();
addClient(ws: ManagedClient): void {
if (!this.userClients.has(ws.userId)) {
this.userClients.set(ws.userId, new Set());
}
this.userClients.get(ws.userId)!.add(ws);
}
removeClient(ws: ManagedClient): void {
this.userClients.get(ws.userId)?.delete(ws);
if (this.userClients.get(ws.userId)?.size === 0) {
this.userClients.delete(ws.userId);
}
for (const roomId of ws.rooms) {
this.roomClients.get(roomId)?.delete(ws);
}
}
sendToUser(userId: string, type: string, payload: unknown): void {
const clients = this.userClients.get(userId);
if (!clients) return;
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type, payload }));
}
}
}
sendToRoom(roomId: string, type: string, payload: unknown, exclude?: string): void {
const clients = this.roomClients.get(roomId);
if (!clients) return;
for (const client of clients) {
if (client.userId !== exclude && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type, payload }));
}
}
}
}
export const wsManager = new WebSocketManager();Scaling Across Multiple Servers with Redis Pub/Sub
The wsManager above only works on a single server. On two servers, a user connected to server A will not receive events published by server B.
The fix: use Redis pub/sub as a message bus between server instances.
// src/lib/wsRedisAdapter.ts
import { publisher, subscriber } from './pubsub';
import { wsManager } from './wsManager';
const CHANNEL = 'ws:events';
// Subscribe to events from other server instances
subscriber.subscribe(CHANNEL);
subscriber.on('message', (channel, message) => {
if (channel !== CHANNEL) return;
const { target, targetId, type, payload } = JSON.parse(message);
// This server delivers to its own connected clients
if (target === 'user') {
wsManager.sendToUser(targetId, type, payload);
} else if (target === 'room') {
wsManager.sendToRoom(targetId, type, payload);
}
});
// Publish an event — all server instances will attempt to deliver it
export async function publishToUser(
userId: string,
type: string,
payload: unknown
): Promise<void> {
await publisher.publish(CHANNEL, JSON.stringify({
target: 'user',
targetId: userId,
type,
payload,
}));
}
export async function publishToRoom(
roomId: string,
type: string,
payload: unknown,
exclude?: string,
): Promise<void> {
await publisher.publish(CHANNEL, JSON.stringify({
target: 'room',
targetId: roomId,
type,
payload,
exclude,
}));
}Now from anywhere in your app — regardless of which server instance handles the request:
import { publishToUser } from '../lib/wsRedisAdapter';
// After an order is paid — notify the user on any connected server
await publishToUser(order.userId, 'order:paid', {
orderId: order.id,
total: order.total,
});Client-Side Reconnection
WebSocket connections drop. Networks change. Servers restart. The client must reconnect automatically.
// client/lib/websocket.ts
class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelay = 1000;
private maxDelay = 30_000;
private handlers = new Map<string, ((payload: unknown) => void)[]>();
constructor(private url: string, private getToken: () => string) {}
connect(): void {
const token = this.getToken();
this.ws = new WebSocket(`${this.url}?token=${token}`);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectDelay = 1000; // Reset backoff on successful connection
};
this.ws.onmessage = (event) => {
try {
const { type, payload } = JSON.parse(event.data);
this.handlers.get(type)?.forEach(h => h(payload));
} catch {}
};
this.ws.onclose = (event) => {
if (event.code === 1008) {
// Authentication failed — do not reconnect
console.error('WebSocket auth failed');
return;
}
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
}
on(type: string, handler: (payload: unknown) => void): void {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type)!.push(handler);
}
send(type: string, payload: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, payload }));
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
// Exponential backoff with jitter
this.reconnectDelay = Math.min(
this.reconnectDelay * 2 + Math.random() * 1000,
this.maxDelay
);
this.connect();
}, this.reconnectDelay);
}
}
// Usage
const ws = new ReconnectingWebSocket(
'wss://yourapi.com/ws',
() => localStorage.getItem('accessToken') || ''
);
ws.connect();
ws.on('order:paid', (payload) => {
showNotification('Your order has been paid!');
});Nginx Config for WebSocket Proxying
location /ws {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Keep WebSocket connections open
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}Without the Upgrade and Connection headers, Nginx will not pass WebSocket connections through — it will try to proxy them as regular HTTP and fail.
Comments (0)
Login to post a comment.