Why WebSockets break the stateless scaling model
A plain HTTP API scales horizontally because each request is self-contained. The load balancer can send it to any instance, and any state the request needs lives in a database or cache that every instance shares. WebSockets break that assumption. After the HTTP Upgrade handshake succeeds, the TCP connection stays open, often for hours, and it belongs to exactly one Node.js process. That process holds the socket object, its event listeners and whatever you attached to it.
Most real-time features build state on top of that socket. Rooms, channel subscriptions, presence lists and per-user socket maps usually start as in-memory structures, such as a Map from room name to a set of sockets. On a single instance this works. With three instances behind a load balancer, each one sees only its own clients. A message published on instance A to the room "project-42" reaches the members connected to A and silently misses the members connected to B and C. Presence counts are wrong too, because each process reports only its share.
That gives you two separate problems:
- Connection affinity. Every request belonging to one client session must reach the process that owns its state. This matters most during the handshake and for any HTTP-based transport fallback.
- Cross-instance delivery. An event raised on one instance must reach sockets held by the others.
Fixing one does not fix the other. Sticky sessions don't deliver messages between instances, and a pub/sub layer doesn't route clients to the right process. This applies to any team running more than one Node.js instance for chat, notifications, live dashboards or collaborative editing.
Load balancer requirements: Upgrade handshake, idle timeouts and heartbeats
Before sticky sessions or pub/sub matter, every hop between the client and Node.js has to keep the WebSocket alive.
Pass the Upgrade handshake through
A WebSocket starts as an HTTP/1.1 request carrying Upgrade: websocket and Connection: Upgrade. These are hop-by-hop headers, so a reverse proxy drops them by default unless told to forward them. In nginx, that means setting proxy_http_version 1.1 and explicitly setting the Upgrade and Connection headers on the upstream request. Without this, the backend never sees an upgrade request and the client's connection attempt fails. Socket.IO may then fall back to long-polling, which hides the misconfiguration.
Idle timeouts drop connections without an error
Proxies and cloud load balancers close connections that carry no traffic for a configured period. For nginx, proxy_read_timeout defaults to 60 seconds. A quiet dashboard socket gets cut with no application error, and the client may only notice when its next send fails. Raise the timeout on the WebSocket route, but don't rely on that alone. NAT gateways and intermediaries you don't control apply their own idle limits.
Pair timeouts with heartbeats
In the ws library, the server calls ws.ping() on an interval, marks the socket alive when a pong event arrives, and calls terminate() on any socket that missed the previous round. Browsers answer ping frames automatically. This keeps traffic flowing and removes dead peers that would otherwise hold memory.
Illustrative estimate (assumed numbers, not a benchmark): dead-peer detection time ≤ 2 × ping interval. Ping interval must be < shortest idle timeout on the path. With a 30 s interval and a 60 s timeout, dead sockets are removed within 60 s and live ones never sit idle long enough to be dropped.
// Heartbeat with the ws library: keep proxied sockets busy and evict dead peers.
const { WebSocketServer } = require('ws');
// Behind nginx, the WebSocket location also needs proxy_http_version 1.1,
// forwarded Upgrade/Connection headers, and a raised proxy_read_timeout.
const wss = new WebSocketServer({ port: 8080 });
// Must be shorter than the smallest idle timeout on the path
// (for example, nginx's default 60 s proxy_read_timeout).
const PING_INTERVAL_MS = 30000;
wss.on('connection', (ws) => {
ws.isAlive = true;
// Browsers reply to ping frames automatically. A pong proves the peer is reachable.
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('error', console.error);
});
const interval = setInterval(() => {
for (const ws of wss.clients) {
// No pong since the last round: the peer is gone, so free its resources now.
if (ws.isAlive === false) {
ws.terminate();
continue;
}
// Assume dead until the next pong arrives, then send a ping.
// The ping also counts as traffic for idle-timeout purposes.
ws.isAlive = false;
ws.ping();
}
}, PING_INTERVAL_MS);
// Stop the timer when the server shuts down.
wss.on('close', () => clearInterval(interval));
A ws server pings every client every 30 seconds, marks a client alive when its pong arrives, and terminates any client that missed the previous round, which keeps the socket active under the proxy's idle timeout.
When you need sticky sessions and how to implement them
Affinity matters because Socket.IO's handshake is spread across several requests. With the default long-polling transport, the client opens a session, then sends a series of separate HTTP requests that carry the session ID. Only the instance that created the session knows that ID, so a request routed elsewhere is rejected and the client loops through reconnects. The polling-to-WebSocket upgrade has the same requirement: the WebSocket probe must reach the instance that holds the polling session. With WebSocket-only transport, the upgrade is a single request. Once it succeeds, the TCP connection itself keeps the client on that instance, so no affinity rule is needed after that point.
There are two common ways to implement stickiness:
| Method | How it routes | Weaknesses |
|---|---|---|
| Cookie-based | Load balancer sets a cookie that identifies the backend | Clients that drop cookies lose affinity. Some non-browser clients don't send cookies. |
| IP-hash | Hash of the client IP selects the backend | Many users behind one NAT or corporate proxy all land on the same node. Adding or removing a node changes the mapping for many clients. |
Prefer cookies when your load balancer supports them.
To avoid stickiness entirely, configure clients for WebSocket-only transport (for example, transports: ['websocket'] in the Socket.IO client). The cost is losing the fallback: clients behind proxies that block WebSockets can no longer connect at all.
Stickiness also skews load. After a scale-out, existing connections stay on the old nodes. New nodes receive only fresh connections and sit mostly idle until clients reconnect.
# Goes inside the http {} block.
# Sets Connection: upgrade only when the client asked for a WebSocket.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream socketio_nodes {
# IP-hash affinity: every request from one client IP (polling requests
# and the WebSocket upgrade probe) reaches the same Node.js instance.
# Weakness: all users behind one NAT or corporate proxy share an IP and
# pile onto one node.
ip_hash;
# Alternative: consistent hashing, so adding or removing a node
# remaps fewer clients than plain ip_hash does.
# hash $remote_addr consistent;
server 10.0.1.11:3000;
server 10.0.1.12:3000;
server 10.0.1.13:3000;
}
server {
listen 80;
server_name chat.example.com;
location /socket.io/ {
proxy_pass
# Required for the upgrade request. After the upgrade, the open TCP
# connection keeps the client pinned to its instance.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
# Keep idle sockets open longer than the Socket.IO ping interval.
proxy_read_timeout 75s;
}
}
An open-source nginx configuration that pins Socket.IO clients to one backend with ip_hash and forwards the headers needed for the polling-to-WebSocket upgrade. A commented alternative uses consistent hashing to reduce remapping when the pool changes.
How Redis pub/sub delivers messages across instances
Stickiness pins a client to one process, but it does nothing for messages that must reach clients elsewhere. When a user on instance A posts to a chat room, A iterates over its own in-memory socket list. Room members connected to instance B never see the message, because B has no idea the event happened. Each Node.js process holds only its own connections, so any broadcast or room emit is local by default.
Redis pub/sub closes that gap. Every instance subscribes to a shared set of channels. When an instance needs to broadcast, it publishes the event to Redis instead of writing to sockets directly. Redis pushes the message to every subscriber, including the sender, and each instance then delivers it to whichever of its own sockets match.
clients ──► load balancer ──┬──► Node A ◄──┐
├──► Node B ◄──┼── Redis (pub/sub)
└──► Node C ◄──┘
each node: PUBLISH on emit, SUBSCRIBE, deliver locally
Redis does not hold sockets or room membership. It only relays events. Each node keeps its own room state and filters incoming events against it.
The Socket.IO adapter pattern
Socket.IO routes every broadcast through an adapter. The default adapter is in-memory. The Redis adapter replaces it. You create two Redis clients, one to publish and one to subscribe, because a connection in subscribe mode cannot issue other commands. You then pass both clients to createAdapter and register the result with io.adapter(). After that, io.to(room).emit() reaches every node without any change to application code.
With plain ws, you build the same pattern yourself. Use a subscriber connection that listens on a channel per room or topic. Have emit helpers publish to that channel with a serialized payload. Add a message handler that looks up local sockets in that room and calls send() on each one.
import { WebSocketServer, WebSocket } from 'ws';
import { createClient } from 'redis';
const pub = createClient({ url: 'redis://redis.internal:6379' });
const sub = pub.duplicate(); // a subscribed connection can't run other commands
await Promise.all([pub.connect(), sub.connect()]);
const rooms = new Map(); // room -> Set<WebSocket>, local to this process only
// Every instance receives every room event and delivers only to its own sockets.
await sub.pSubscribe('room:*', (message, channel) => {
const room = channel.slice('room:'.length);
for (const ws of rooms.get(room) ?? []) {
if (ws.readyState === WebSocket.OPEN) ws.send(message);
}
});
// Emit helper: publish to Redis instead of writing to local sockets directly.
function emitToRoom(room, payload) {
return pub.publish(`room:${room}`, JSON.stringify(payload));
}
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
ws.joined = new Set();
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'join') {
if (!rooms.has(msg.room)) rooms.set(msg.room, new Set());
rooms.get(msg.room).add(ws);
ws.joined.add(msg.room);
} else if (msg.type === 'chat') {
emitToRoom(msg.room, { room: msg.room, text: msg.text });
}
});
ws.on('close', () => {
for (const room of ws.joined) rooms.get(room)?.delete(ws);
});
});
A hand-rolled Redis pub/sub layer for plain ws, using node-redis v4: each instance publishes room events to Redis, subscribes to all room channels, and fans messages out only to the sockets it holds locally.
Delivery guarantees, fan-out cost and the alternatives
Redis pub/sub is fire-and-forget. A published message goes to whoever is subscribed at that instant and is then discarded. There is no persistence, acknowledgement or replay. If an instance's subscriber connection drops for two seconds during a network blip, every message published in that window is gone, and neither side knows it happened.
Fan-out is the second cost. On a single shared channel, every instance receives every message, even when none of its sockets are in the target room. Two mitigations help. Per-room channels let an instance subscribe only while it holds members of that room. Redis Cluster's sharded pub/sub spreads channel traffic across shards instead of broadcasting it to all nodes.
Illustrative estimate (assumed numbers, not a benchmark): wasted deliveries/s = M × (N − R), where M is messages/s, N is instances and R is instances with members in the room. With M = 1,000, N = 10 and R = 2, that is 8,000 wasted deliveries per second.
| Option | Delivery | Replay | Main trade-off |
|---|---|---|---|
| Redis pub/sub | At-most-once | None | Silent loss on disconnect |
| Redis Streams | At-least-once with consumer groups | By ID | Trimming, ack handling, duplicates |
| Durable broker | Configurable | Yes | Another system to operate |
| Library adapter | Inherits its backend | Rarely | Locked to the library's wire protocol |
| Custom pub/sub layer | Your design | Your design | Code you maintain |
| No stickiness, WebSocket-only | N/A | N/A | Clients cannot use HTTP fallback transports |
Replay is not free. It requires per-room sequence numbers, a retained log, and clients that send their last-seen sequence on reconnect. The server must then handle gaps, duplicates and expired history.
Reconnection, graceful shutdown and deploys
Every connection will eventually land on a different instance than the one it started on, because of deploys, autoscaling, or a crashed pod. Design for that from the start. Session data, room membership and authoritative state belong in a shared store such as Redis or your database, not in a Map inside the process. On reconnect, the client presents its token and last-seen sequence number. The new instance rebuilds subscriptions from the shared store and replays or resyncs from there.
Graceful shutdown
On SIGTERM, the server should:
- Fail its readiness check so the load balancer stops routing new upgrades to it.
- Stop accepting new connections with
server.close(). Existing sockets stay open. - Close the remaining sockets in batches over a drain window. Use close code 1001 ("going away") so clients know to reconnect rather than surface an error.
- Exit before the orchestrator's termination grace period expires.
Spreading closes over time matters. If one instance drops 20,000 sockets at once, those clients all hit the remaining instances in the same second.
Client backoff
Clients should reconnect with exponential backoff, a ceiling, and full jitter. That means a random delay between zero and the current cap. Without jitter, clients that disconnected together retry together, and every retry wave lands on the cluster in sync.
Resync under load
Resync is expensive. Each reconnect means authentication, subscription rebuilds and a replay or snapshot read. During an incident, that work arrives exactly when capacity is lowest. Rate-limit handshakes per instance, and cap concurrent resyncs. Shed excess load with a retry hint instead of queueing it. Otherwise, recovery traffic keeps a partial outage from ever ending.
import http from 'node:http';
import { WebSocketServer } from 'ws';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const DRAIN_MS = 20_000; // keep well under the orchestrator's grace period (e.g. 30s)
const BATCHES = 10; // spread closes so clients don't all reconnect in the same second
let ready = true;
const server = http.createServer((req, res) => {
// Readiness probe: return 503 once draining so the LB stops routing new upgrades here.
if (req.url === '/readyz') return res.writeHead(ready ? 200 : 503).end();
res.writeHead(404).end();
});
const wss = new WebSocketServer({ server });
server.listen(8080);
process.once('SIGTERM', async () => {
ready = false;
await sleep(5_000); // let the LB notice the failing readiness check
server.close(); // stop accepting new connections; upgraded sockets stay open
const sockets = [...wss.clients];
const perBatch = Math.max(1, Math.ceil(sockets.length / BATCHES));
for (let i = 0; i < sockets.length; i += perBatch) {
// 1001 = going away: clients treat it as a reconnect signal, not an error.
for (const ws of sockets.slice(i, i + perBatch)) ws.close(1001, 'server restarting');
await sleep(DRAIN_MS / BATCHES);
}
// Drop anything that never completed the close handshake, then exit before SIGKILL.
for (const ws of wss.clients) ws.terminate();
process.exit(0);
});
A SIGTERM handler using the ws library that fails readiness, stops accepting new connections, and closes existing sockets with code 1001 in timed batches so the reconnect load is spread across the drain window.
Observability, cost and engineering-time trade-offs
Metrics that separate imbalance from loss
Track these per instance, not only as fleet totals:
- Open connections per instance. Long-lived sockets stay where they landed, so a skew after a deploy or scale-out means imbalance, not loss.
- Connection churn (opens and closes per minute). High churn with flat totals means clients are reconnecting, usually because of idle timeouts or missed heartbeats.
- Pub/sub lag (receive time minus publish timestamp, per subscriber). Rising lag with steady connections points to a slow consumer or an overloaded Redis.
- Redis connection health. Alert on every subscriber disconnect. Pub/sub is fire-and-forget, so anything published during the gap is gone, and no other metric will show it.
Redis is now a shared dependency. If it fails, each instance keeps serving its local sockets while cross-instance broadcasts stop without any error. Run it with replication and failover, and decide in advance whether local-only delivery is an acceptable degraded mode.
Engineering time
A Socket.IO adapter saves real effort because rooms and broadcasts work across nodes without custom routing. That changes once you need guaranteed delivery, replay or per-user routing. The adapter does not provide these, so you build and maintain them yourself.
| Option | Fits when | Main cost |
|---|---|---|
| Single instance | Load fits one process and brief deploy downtime is acceptable | No redundancy |
| Sticky sessions + Redis pub/sub | You run multiple instances and best-effort delivery is acceptable | Redis availability, messages lost during reconnects |
| Managed service or durable log | Messages must not be lost or must be replayable | Operational overhead or service fees |
Start with the simplest row that meets your delivery requirement. Move down the table only when a metric above shows you need to.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to scaling websockets nodejs, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.