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.

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.
// ❌ 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.
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.
// ❌ 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.
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.
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.


