Skip to main content
WebSockets in Production: Patterns That Scale

WebSockets in Production: Patterns That Scale

Aug 10, 2025

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

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.

© 2026 Tawan. All rights reserved.