Skip to content

WebSocket Architecture Patterns for Scalable Real-Time Apps

Design scalable WebSocket architectures: connection management, room-based pub/sub, heartbeats, reconnection and horizontal scaling to thousands.

4 min read
A WebSocket server architecture diagram showing load balancer distributing connections across multiple server instances with Redis pub/sub

The Single-Server Ceiling

WebSocket connections are persistent and stateful. A single server holding all connections works until you need a second server. At that point, a message sent to Server A never reaches clients on Server B. Scaling WebSocket applications requires architectural decisions that traditional HTTP apps never face.

Connection Management

Every WebSocket connection needs tracking. Without a registry, you cannot broadcast to specific users, rooms, or channels.

tstypescript
// ❌ Storing connections in a plain array — no way to target specific users
const connections: WebSocket[] = [];
 
// ✅ Connection registry with metadata for targeted messaging
interface ConnectionMeta {
  userId: string;
  rooms: Set<string>;
  connectedAt: number;
  lastPing: number;
}
 
class ConnectionRegistry {
  private connections = new Map<string, WebSocket>();
  private metadata = new Map<string, ConnectionMeta>();
 
  add(connectionId: string, ws: WebSocket, userId: string): void {
    this.connections.set(connectionId, ws);
    this.metadata.set(connectionId, {
      userId,
      rooms: new Set(),
      connectedAt: Date.now(),
      lastPing: Date.now(),
    });
  }
 
  remove(connectionId: string): void {
    const meta = this.metadata.get(connectionId);
    if (meta) {
      for (const room of meta.rooms) {
        this.leaveRoom(connectionId, room);
      }
    }
    this.connections.delete(connectionId);
    this.metadata.delete(connectionId);
  }
 
  getByUser(userId: string): WebSocket[] {
    const sockets: WebSocket[] = [];
    for (const [connId, meta] of this.metadata) {
      if (meta.userId === userId) {
        const ws = this.connections.get(connId);
        if (ws) sockets.push(ws);
      }
    }
    return sockets;
  }
 
  getByRoom(room: string): WebSocket[] {
    const sockets: WebSocket[] = [];
    for (const [connId, meta] of this.metadata) {
      if (meta.rooms.has(room)) {
        const ws = this.connections.get(connId);
        if (ws) sockets.push(ws);
      }
    }
    return sockets;
  }
 
  joinRoom(connectionId: string, room: string): void {
    this.metadata.get(connectionId)?.rooms.add(room);
  }
 
  leaveRoom(connectionId: string, room: string): void {
    this.metadata.get(connectionId)?.rooms.delete(room);
  }
}

Heartbeat and Dead Connection Detection

TCP does not always notify you when a connection drops. Mobile networks, laptop lids closing, and network switches silently kill connections. Without heartbeats, your registry accumulates dead connections that waste memory and produce send errors.

tstypescript
class HeartbeatManager {
  private intervals = new Map<string, NodeJS.Timeout>();
  private readonly PING_INTERVAL = 30_000;
  private readonly PONG_TIMEOUT = 10_000;
 
  start(
    connectionId: string,
    ws: WebSocket,
    onDead: (connectionId: string) => void
  ): void {
    const interval = setInterval(() => {
      if (ws.readyState !== WebSocket.OPEN) {
        this.stop(connectionId);
        onDead(connectionId);
        return;
      }
 
      let pongReceived = false;
 
      const onPong = () => {
        pongReceived = true;
      };
      ws.once("pong", onPong);
      ws.ping();
 
      setTimeout(() => {
        ws.removeListener("pong", onPong);
        if (!pongReceived) {
          ws.terminate();
          this.stop(connectionId);
          onDead(connectionId);
        }
      }, this.PONG_TIMEOUT);
    }, this.PING_INTERVAL);
 
    this.intervals.set(connectionId, interval);
  }
 
  stop(connectionId: string): void {
    const interval = this.intervals.get(connectionId);
    if (interval) {
      clearInterval(interval);
      this.intervals.delete(connectionId);
    }
  }
}

Client-Side Reconnection

Clients must handle disconnections gracefully. Exponential backoff with jitter prevents thousands of clients from reconnecting simultaneously after a server restart.

tstypescript
// ❌ Reconnect immediately in a tight loop — hammers the server
// ws.onclose = () => { connect(); };
 
// ✅ Exponential backoff with jitter
class ReconnectingWebSocket {
  private ws: WebSocket | null = null;
  private attempt = 0;
  private readonly maxDelay = 30_000;
  private readonly baseDelay = 1_000;
 
  constructor(
    private url: string,
    private onMessage: (data: string) => void
  ) {
    this.connect();
  }
 
  private connect(): void {
    this.ws = new WebSocket(this.url);
 
    this.ws.onopen = () => {
      this.attempt = 0; // Reset on successful connection
    };
 
    this.ws.onmessage = (event) => {
      this.onMessage(event.data as string);
    };
 
    this.ws.onclose = () => {
      this.scheduleReconnect();
    };
  }
 
  private scheduleReconnect(): void {
    const delay = Math.min(
      this.baseDelay * Math.pow(2, this.attempt),
      this.maxDelay
    );
    // Add jitter: random value between 0 and delay
    const jitter = Math.random() * delay;
    const finalDelay = delay + jitter;
 
    this.attempt++;
 
    setTimeout(() => this.connect(), finalDelay);
  }
 
  send(data: string): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(data);
    }
  }
 
  close(): void {
    this.attempt = Infinity; // Prevent reconnection
    this.ws?.close();
  }
}

Horizontal Scaling with Pub/Sub

When you add a second WebSocket server, messages on one server must reach clients on the other. Redis Pub/Sub or a similar message broker bridges this gap.

tstypescript
import { createClient } from "redis";
 
class ScalableMessageBroker {
  private publisher;
  private subscriber;
  private registry: ConnectionRegistry;
 
  constructor(registry: ConnectionRegistry, redisUrl: string) {
    this.registry = registry;
    this.publisher = createClient({ url: redisUrl });
    this.subscriber = createClient({ url: redisUrl });
  }
 
  async initialize(): Promise<void> {
    await this.publisher.connect();
    await this.subscriber.connect();
  }
 
  async subscribeToRoom(room: string): Promise<void> {
    await this.subscriber.subscribe(`room:${room}`, (message) => {
      // Deliver to local connections only
      const sockets = this.registry.getByRoom(room);
      for (const ws of sockets) {
        if (ws.readyState === WebSocket.OPEN) {
          ws.send(message);
        }
      }
    });
  }
 
  async publishToRoom(room: string, message: string): Promise<void> {
    // Publishes to ALL servers subscribed to this room
    await this.publisher.publish(`room:${room}`, message);
  }
 
  async publishToUser(userId: string, message: string): Promise<void> {
    await this.publisher.publish(`user:${userId}`, message);
  }
}

Message Protocol Design

Raw strings over WebSockets become unmaintainable. Define a typed message protocol that supports versioning and routing.

tstypescript
interface WsMessage<T = unknown> {
  type: string;
  payload: T;
  timestamp: number;
  correlationId?: string;
}
 
type MessageHandler<T = unknown> = (
  connectionId: string,
  payload: T
) => void | Promise<void>;
 
class MessageRouter {
  private handlers = new Map<string, MessageHandler>();
 
  on<T>(type: string, handler: MessageHandler<T>): void {
    this.handlers.set(type, handler as MessageHandler);
  }
 
  async route(connectionId: string, raw: string): Promise<void> {
    const message: WsMessage = JSON.parse(raw);
    const handler = this.handlers.get(message.type);
 
    if (!handler) {
      console.warn(`No handler for message type: ${message.type}`);
      return;
    }
 
    await handler(connectionId, message.payload);
  }
}
 
// Usage
const router = new MessageRouter();
 
router.on<{ room: string }>("join_room", (connId, payload) => {
  registry.joinRoom(connId, payload.room);
});
 
router.on<{ room: string; text: string }>(
  "chat_message",
  async (connId, payload) => {
    const message = JSON.stringify({
      type: "chat_message",
      payload: { text: payload.text, from: connId },
      timestamp: Date.now(),
    });
    await broker.publishToRoom(payload.room, message);
  }
);

Key Takeaways

WebSocket scaling is fundamentally different from HTTP scaling because connections are stateful and persistent. Build a connection registry that tracks users and rooms from the start. Implement heartbeats to detect dead connections that TCP silently loses.

Client reconnection must use exponential backoff with jitter—without jitter, server restarts trigger thundering herd reconnections. For horizontal scaling, use Redis Pub/Sub or a message broker to bridge messages between server instances. Define a typed message protocol early; raw string messages become impossible to maintain as the application grows. Every decision you defer—connection tracking, dead connection cleanup, cross-server messaging—becomes harder to retrofit once you have users relying on the single-server behavior.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX