ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeWebSockets in Node.js: Real-Time Features Without the Complexity Tax

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.

#Node.js WebSockets#ws library Node.js#SSE Server-Sent Events Node.js#WebSocket authentication#Redis pub/sub WebSocket scaling#WebSocket reconnection#real-time Node.js 2026#WebSocket rooms
Z
ZyVOP

Senior Developer

May 27, 2026
9 min read
4 views
WebSockets in Node.js: Real-Time Features Without the Complexity Tax

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.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design