Scaling WebSockets in Node.js Beyond a Single Server
WebSockets are easy until the second server arrives. Sticky sessions, Redis pub/sub adapters and connection state — what changes when realtime has to scale.
A WebSocket feature that works flawlessly on one server has a habit of breaking in strange, intermittent ways the day you add a second. Messages reach some users and not others; presence lists disagree with reality; reconnections fail one time in three. None of this is bad luck. It is the direct consequence of two facts: connections are stateful and long-lived, and each server only knows about its own.
The broadcast problem
Picture a chat room with users connected across two instances. When instance A receives a message and broadcasts it, only the sockets attached to A hear it — B's users see nothing. Every horizontally scaled WebSocket system therefore needs a backplane: a channel through which instances forward events to each other. Redis pub/sub is the standard choice, and Socket.IO packages the whole pattern as an adapter.
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
const pub = createRedisClient();
const sub = pub.duplicate();
const io = new Server(httpServer, {
adapter: createAdapter(pub, sub),
});
io.to('room:42').emit('message', payload); // now reaches every instanceWith the adapter in place, rooms and broadcasts behave as if there were one big server. If you are on plain ws rather than Socket.IO, the same idea applies — you just wire the pub/sub forwarding yourself and gain a new appreciation for the adapter.
Sticky sessions and the handshake
Socket.IO's HTTP long-polling fallback performs several requests before upgrading, and all of them must land on the same instance, so your load balancer needs sticky sessions enabled. If every client you control can be trusted to support WebSockets — true for most modern products — the cleaner route is forcing transports to websocket only, which removes the stickiness requirement and a whole category of failure with it.
State has to move out of the process
- Presence (who is online) belongs in Redis with a TTL refreshed by heartbeats, not in a local Map.
- Rooms and subscriptions should be re-established by the client on reconnect from its own state — treat the server copy as a cache.
- Anything a user would miss while briefly disconnected needs persistence and replay; pub/sub alone drops messages for absent subscribers.
- Authenticate at connection time with a short-lived token, and re-verify on sensitive actions — a socket can outlive a session.
That reconnect point is the one that saves you during deploys. A rolling restart severs every connection on the old instances; if clients reconnect and resubscribe idempotently, a deploy is a ripple rather than an outage.
Heartbeats matter more than they look, too. TCP will happily keep a dead connection on your books long after a phone drops off wifi, inflating presence counts and leaking per-connection state. Socket.IO pings for you; on raw ws, send pings on an interval and terminate any socket that misses a couple of pongs.
Know your per-connection budget
Each idle connection costs sockets, file descriptors and a slice of heap, and the practical ceiling per Node instance is usually file descriptors and event-loop pressure during broadcast storms rather than raw memory. Raise ulimits deliberately, monitor event-loop delay, and load test with a realistic ratio of idle to chatty connections — ten thousand quiet sockets behave nothing like ten thousand active ones. When broadcasts to very large rooms become the bottleneck, batching and coalescing messages buys far more than instance count.
Scaling realtime is mostly the discipline of admitting the process is ephemeral: connections are re-establishable, state lives elsewhere, and every instance is disposable. Get those three right and the second server — and the tenth — stop being scary.
Planning a realtime feature and want the architecture right first time? Talk to the STRCLI backend team.