Scaling WebSockets
Why a socket is pinned to one server, solving it with a Redis pub/sub backplane, and rooms/channels.
The problem: a connection is pinned to one server
A WebSocket connection, unlike a stateless HTTP request, is long-lived — once a client connects, that TCP connection stays open on one specific server process, for as long as the client stays connected (potentially hours). This creates a problem the moment you run more than one server instance behind a load balancer, which is exactly the setup almost every production system uses for redundancy and horizontal scaling.
┌──────────┐
Client A ---> │ Load │ ---> Server 1 (Client A's socket lives HERE)
Client B ---> │ Balancer │ ---> Server 2 (Client B's socket lives HERE)
└──────────┘
Client A is connected to Server 1. Client B is connected to Server 2. Now imagine Client A sends a chat message that Client B needs to receive: Server 1 has the message, but has no direct connection to Client B at all — Client B's socket lives on a completely different process, possibly a different machine entirely. Server 1 simply cannot call .send() on a socket it doesn't hold a reference to.
This is fundamentally different from a stateless REST API, where any server instance can handle any request because no state is pinned to a specific process — with WebSockets, the connection itself is server-specific state.
The solution: a pub/sub backplane
The standard fix is to give every server instance a way to broadcast a message to every other server instance, not just its own connected clients. Each server still only holds its own clients' actual socket connections, but it also subscribes to a shared messaging channel — so when any server needs to deliver a message to any client, it publishes to that shared channel, and every server (including ones with no relevant clients at all) receives it and delivers it only to its own matching local connections.
┌──────────┐
Client A ---> │ Load │ ---> Server 1 --\
Client B ---> │ Balancer │ ---> Server 2 --- > Redis Pub/Sub
└──────────┘ --/
Client A sends a message
-> Server 1 publishes it to a Redis channel
-> Redis fans it out to every subscribed server (1 and 2)
-> Server 2 sees Client B is one of ITS local connections and forwards it
-> Server 1 sees none of ITS local connections need it, and does nothing further
Redis pub/sub as the common solution
Redis pub/sub is the most widely used backplane for exactly this reason: it's simple, fast, and most stacks already run Redis for caching or sessions anyway. Every server process both publishes messages it needs to broadcast, and subscribes to receive messages other servers publish:
import { WebSocketServer } from 'ws';
import { createClient } from 'redis';
const wss = new WebSocketServer({ port: 8080 });
const localClients = new Map(); // socket -> userId, for this server instance only
const publisher = createClient();
const subscriber = createClient();
await publisher.connect();
await subscriber.connect();
// Every server instance subscribes to the same shared channel
await subscriber.subscribe('chat-messages', (message) => {
const payload = JSON.parse(message);
// Deliver only to clients actually connected to THIS server instance
for (const [socket] of localClients) {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify(payload));
}
}
});
wss.on('connection', (socket) => {
localClients.set(socket, true);
socket.on('message', async (data) => {
// Publish so every server instance (including this one) delivers it
await publisher.publish('chat-messages', data.toString());
});
socket.on('close', () => localClients.delete(socket));
});
Each server no longer needs to know or care which other server a given recipient is connected to — it just publishes to Redis, and Redis makes sure every server instance (and therefore every connected client, wherever they landed) sees the message.
Rooms and channels
Broadcasting every message to every connected client on every server, regardless of relevance, wastes bandwidth once an application has many independent conversations happening at once (multiple chat rooms, a document editor open by many separate groups). The rooms (sometimes called channels) pattern solves this by scoping pub/sub topics to a specific group instead of one global firehose:
// Publish only to subscribers of THIS room's channel
await publisher.publish(`chat-room:${roomId}`, data.toString());
// Each server only subscribes to rooms it actually has local clients in
await subscriber.subscribe(`chat-room:${roomId}`, handleRoomMessage);
Libraries like Socket.IO (built on top of WebSockets, with additional features like automatic reconnection and fallback transports) formalize this exact pattern with a first-class room concept and a built-in Redis adapter, so most teams don't hand-roll the pub/sub wiring shown above — but understanding what's happening underneath is exactly what lets you reason about scaling limits and debug production issues when they arise.
Common mistakes
- Deploying multiple WebSocket server instances behind a load balancer with no backplane at all, and being confused why messages only reach some connected clients (exactly the ones that happen to share a server instance with the sender).
- Broadcasting every message globally instead of scoping to rooms/channels once an application has many independent groups — this wastes bandwidth and CPU delivering irrelevant messages to servers with no interested local clients.
- Forgetting that a load balancer must be configured for sticky sessions or protocol-aware routing for WebSockets — an ordinary round-robin HTTP load balancer can disrupt the upgrade handshake or misroute reconnections if not configured with WebSockets in mind.
Interview questions
Q: Why can't you just run multiple WebSocket server instances behind a load balancer the same way you'd scale a stateless REST API? Because a WebSocket connection is long-lived and pinned to the specific server process that accepted it — unlike a stateless HTTP request, no other instance holds a reference to that socket. If two clients that need to communicate end up connected to different server instances, one instance has no way to deliver a message directly to a client connected to another instance.
Q: How does a Redis pub/sub backplane solve the multi-instance WebSocket scaling problem? Every server instance subscribes to a shared Redis channel. When any server needs to deliver a message, it publishes it to that channel instead of trying to reach the recipient directly; Redis fans the message out to every subscribed server instance, and each one delivers it only to the clients actually connected to it locally. This means no server instance needs to know which instance any other client is connected to.
Q: What's the purpose of "rooms" or "channels" in a scaled WebSocket system? They scope pub/sub messages to a relevant subset of connections (e.g., one specific chat room or document) instead of broadcasting every message to every connected client across every server instance. This avoids wasting bandwidth and processing delivering irrelevant messages to clients (and servers) that have no interest in a particular conversation or resource.