WebSocket scaling: sticky routing, backplanes, and connection math

What it is

A WebSocket connection is stateful and long-lived, which breaks the assumption every stateless-HTTP scaling technique rests on. Three consequences follow, and they are the whole topic:

A connection is pinned to one process. Once established, every message for that client must reach that server, so any other server holding a message for that client has a routing problem.

Capacity is measured in concurrent connections, not requests per second. A server handling 50,000 requests per second might hold 200,000 idle WebSocket connections, and the constraints are file descriptors, memory per connection and ephemeral ports rather than CPU.

A deploy disconnects everyone. Rolling a stateless service moves traffic; rolling a WebSocket service severs every connection on the replaced instance, and they all reconnect at once.

Two architectures for the routing problem:

Sticky routingPub/sub backplane
HowThe load balancer pins a client to a serverAny server can publish; all servers subscribe
Fan-outRequires knowing which server holds the clientBroadcast, every server filters
ScalingConnections scale, cross-server messaging does notBoth scale, at the cost of a message bus
FailureThe server dies, the client reconnects elsewhereSame, plus the bus is a dependency
Right forClient-to-server request/response over a socketServer-initiated fan-out, chat, presence

What this is confused with: needing one or the other. Almost every real system needs both: sticky routing so a client's messages reach a consistent process, and a backplane so a message generated anywhere reaches the process holding the recipient.

The problem it solves

HTTP load balancing assumes any server can handle any request. WebSockets break that, and the naive deployment fails in a specific order:

1. Two servers, round-robin. Client A on server 1, client B on server 2.
   A sends a message for B. Server 1 has no connection to B. The message
   is silently dropped.

2. Add a backplane. Now it works, and every server receives every message
   and discards 99.9% of them.

3. Scale to 40 servers and 400,000 connections. The backplane is now
   delivering every message to 40 servers. Bus throughput is the limit,
   not connections.

4. Deploy. All 400,000 clients reconnect within seconds. The reconnect
   storm exceeds the accept rate, clients retry, and the storm feeds itself.

Step 4 is the one that takes systems down, and it is the failure that has nothing to do with steady-state capacity.

The resource arithmetic that decides the design:

Per idle WebSocket connection, typical Node.js/Go server:
  kernel socket buffers:     ~4-16 KB (tunable, and the default is generous)
  application state:         ~1-10 KB (session, subscriptions, buffers)
  file descriptor:           1
  conntrack entry (if NAT):  1

100,000 connections:
  memory:                    ~0.5-2.5 GB
  file descriptors:          100,000  (default ulimit is 1024)
  ephemeral ports at the LB: 100,000 toward one backend IP:port

The ephemeral port limit is the one people meet first and understand last. A load balancer opening connections to a backend has about 28,000 ephemeral ports per destination ip:port tuple, so 28,000 connections per backend per LB instance unless you widen the range or add backend addresses.

Mechanics

Sticky routing

upstream ws_backend {
    ip_hash;                        # or `hash $cookie_sid consistent;`
    server ws1.internal:8080;
    server ws2.internal:8080;
    server ws3.internal:8080;
}

ip_hash is the weakest form and it is what most examples show. It breaks with carrier-grade NAT (thousands of mobile users behind one IP land on one server), it rebalances everything when a server is added or removed, and it gives no control.

Consistent hashing on a client-supplied key is the right version:

upstream ws_backend {
    hash $arg_client_id consistent;    # `consistent` = ketama: only 1/N moves
    server ws1.internal:8080;
    server ws2.internal:8080;
}
Adding a 4th server to 3:
  plain hash:       ~75% of clients move to a different server
  consistent hash:  ~25% move

On a WebSocket service every move is a disconnect, so the difference between 75 percent and 25 percent of clients reconnecting is the difference between an incident and a blip.

Layer 7 versus layer 4 matters here, and it connects to the gRPC load balancing page: an L4 balancer pins the TCP connection, which is what you want, and it cannot read a cookie or a path, so the key must be derivable from the connection. An L7 balancer can route on anything and must handle the upgrade correctly.

The backplane

                     ┌──────────┐
   client A ────────▶│ server 1 │──publish──┐
                     └──────────┘           │
                                       ┌────▼─────┐
                     ┌──────────┐      │  Redis   │
   client B ◀────────│ server 2 │◀─sub─│  Pub/Sub │
                     └──────────┘      │  / NATS  │
                                       │  / Kafka │
                     ┌──────────┐      └──────────┘
   client C ◀────────│ server 3 │◀─sub──────┘
                     └──────────┘
// The naive version: every server subscribes to everything.
redis.subscribe('messages');
redis.on('message', (channel, payload) => {
  const msg = JSON.parse(payload);
  const socket = localConnections.get(msg.recipientId);
  if (socket) socket.send(payload);       // 99.9% of the time: not here, discard
});

Every server receives every message. At 40 servers that is 40x the message volume on the bus, and the bus becomes the scaling limit long before connections do.

Channel-per-topic reduces the fan-out to what is needed:

// Subscribe only to the channels this server actually holds subscribers for.
function onClientSubscribe(clientId, topic) {
  localTopics.get(topic).add(clientId);
  if (localTopics.get(topic).size === 1) {
    redis.subscribe(`topic:${topic}`);       // first local subscriber: subscribe
  }
}
function onClientLeave(clientId, topic) {
  localTopics.get(topic).delete(clientId);
  if (localTopics.get(topic).size === 0) {
    redis.unsubscribe(`topic:${topic}`);     // last one left: unsubscribe
  }
}
Broadcast to 1 recipient, 40 servers:
  subscribe-to-everything:   40 deliveries, 39 discarded
  channel-per-topic:          1 delivery

The cost is subscription churn, which for a chat application with users joining and leaving rooms constantly is its own load, and Redis Pub/Sub handles subscription changes less efficiently than message delivery.

Choosing the bus:

Redis Pub/Sub:   fire-and-forget, no persistence, no delivery guarantee.
                 A subscriber that is down misses messages permanently.
                 Simplest, and correct for presence and ephemeral state.
Redis Streams:   persistent, consumer groups, replay by ID.
                 Right when a missed message matters.
NATS:            purpose-built, very low latency, JetStream for persistence.
Kafka:           persistent, ordered per partition, replayable.
                 Heavy for this, and right when the messages are also
                 a business event stream.

Redis Pub/Sub losing messages during a subscriber restart is the property that surprises people, because it is invisible in testing and appears as "occasionally a message is not delivered."

Connection resource math

# 1. File descriptors. The default of 1024 is the first wall.
$ ulimit -n
1024
# systemd unit:
LimitNOFILE=1048576
# and the system-wide ceiling:
fs.file-max = 2097152
# 2. Ephemeral ports, at the LOAD BALANCER, toward each backend.
net.ipv4.ip_local_port_range = 1024 65535       # ~64,500 per backend ip:port
# Each additional backend PORT multiplies this:
#   backend listening on 8080-8083 = 4x the port budget
# 3. conntrack, if there is NAT anywhere in the path.
net.netfilter.nf_conntrack_max = 2097152
net.netfilter.nf_conntrack_tcp_timeout_established = 86400   # long-lived!

Long-lived connections and conntrack interact badly: the established timeout must exceed your connection lifetime or entries are evicted while the connection is alive, which drops traffic silently. See TCP tuning.

# 4. Socket memory. The defaults are generous for many small connections.
net.ipv4.tcp_rmem = 4096 87380 6291456
net.ipv4.tcp_wmem = 4096 16384 4194304
# For many idle connections, lower the DEFAULT (middle value):
net.ipv4.tcp_rmem = 4096 16384 6291456
net.ipv4.tcp_wmem = 4096 16384 4194304
100,000 connections:
  default rmem+wmem:   100,000 x ~104 KB = ~10 GB
  tuned:               100,000 x ~32 KB  = ~3.2 GB

Socket buffer defaults are sized for throughput per connection, not for connection count, and that is a 7 GB difference on one machine.

# 5. Keepalives, so dead connections are reaped rather than accumulating.
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 6

Plus application-level pings, because a TCP connection through a NAT or a load balancer can be silently dropped by an intermediary while both endpoints believe it is alive. WebSocket ping/pong frames at 30 seconds is the standard, and it is also what keeps intermediaries from timing the connection out.

The reconnect storm

This is the failure that takes down WebSocket systems, and it has nothing to do with steady-state capacity.

40 servers, 400,000 connections. One server is replaced.
  -> 10,000 clients disconnect simultaneously
  -> all reconnect within ~1 second (naive clients)
  -> 10,000 TCP handshakes + 10,000 TLS handshakes + 10,000 auth calls
     in one second, spread over 39 servers
  -> auth service saturates, some reconnects fail
  -> those clients retry immediately
  -> the storm sustains itself

Exponential backoff with full jitter is the client-side requirement:

let attempt = 0;
function reconnect() {
  const base = Math.min(30000, 1000 * Math.pow(2, attempt));
  const delay = Math.random() * base;         // FULL jitter, not base + jitter
  attempt++;
  setTimeout(connect, delay);
}
socket.onopen = () => { attempt = 0; };       // reset ONLY after a successful open

Full jitter (random(0, base)) rather than base + random() is what actually spreads the herd: the second form still has every client waiting at least base, so the storm is delayed rather than dispersed.

Server-side, the deploy should drain rather than sever:

process.on('SIGTERM', async () => {
  server.close();                                  // stop accepting new connections
  // Tell clients to reconnect, spread over a window.
  const clients = [...connections];
  const windowMs = 60_000;
  clients.forEach((c, i) => {
    setTimeout(() => c.close(1001, 'server_going_away'), (i / clients.length) * windowMs);
  });
  await sleep(windowMs + 5000);
  process.exit(0);
});

Closing with code 1001 (going away) over a 60-second window converts a 10,000-client instantaneous storm into 167 reconnects per second, which is a normal rate. terminationGracePeriodSeconds must exceed the window or Kubernetes kills the process mid-drain.

A worked example: a deploy that took down a platform

A collaborative document editor. About 340,000 concurrent WebSocket connections at peak, 60 Node.js servers, Redis Pub/Sub backplane, behind an L7 load balancer.

The incident:

14:02  routine deploy begins, rolling 6 servers at a time
14:02  ~34,000 clients disconnected
14:02  all reconnect within ~2 s
14:03  auth service (JWT validation, a database lookup) saturates: p99 4s -> 40s
14:03  reconnects time out; clients retry IMMEDIATELY (no backoff)
14:04  remaining 54 servers at 100% CPU handling handshakes
14:05  healthy servers fail liveness probes -> Kubernetes restarts them
14:05  their connections drop -> more reconnects
14:12  full outage
14:41  recovered by pausing the deploy, scaling auth 5x, and rate-limiting
       connections at the LB

Twenty-nine minutes of outage from a routine deploy, and steady-state capacity was never the constraint.

Root causes, in order of contribution:

1. No client backoff. The client reconnected immediately on close and retried immediately on failure.

// What it was:
socket.onclose = () => connect();      // immediate, forever
// What it became: full jitter, capped, reset only on success.
let attempt = 0;
socket.onclose = (e) => {
  if (e.code === 1000) return;                        // clean close: do not reconnect
  const base = Math.min(30_000, 1_000 * 2 ** attempt);
  attempt++;
  setTimeout(connect, Math.random() * base);          // FULL jitter
};
socket.onopen = () => { attempt = 0; };

2. Auth on every reconnect, hitting the database. A JWT was validated by a database lookup of the session, so 34,000 reconnects were 34,000 queries in two seconds.

// Before: a DB lookup per connection.
// After: verify the JWT signature locally; check revocation against a
// bloom filter refreshed every 30s.
const claims = jwt.verify(token, publicKey);          // local, ~50 us
if (revokedFilter.mightContain(claims.jti)) {         // in-memory
  await checkRevocationInDb(claims.jti);              // rare
}
auth cost per reconnect:  ~14 ms (DB) -> ~0.06 ms (local)

3. No drain on shutdown. SIGTERM closed the server immediately, severing all connections at once.

// After: spread closes over 90 seconds, with code 1001.
terminationGracePeriodSeconds: 120     # must EXCEED the drain window

4. The backplane amplified it. Every server subscribed to a global channel and filtered locally.

steady state:      12,000 msg/s x 60 servers = 720,000 deliveries/s
during the storm:  presence updates for 34,000 reconnecting clients,
                   each broadcast to 60 servers
                   -> Redis at 100% CPU on a single thread
// After: one channel per document, subscribed only where there are
// local participants.
redis.subscribe(`doc:${docId}`);
deliveries/s at steady state:  720,000 -> 14,000     (-98%)
Redis CPU:                     78% -> 6%

Redis Pub/Sub is single-threaded, so a busy backplane is a single-core limit, and this was the second-largest contributor.

5. Connection limits at the LB were absent. Nothing bounded the accept rate, so a storm reached the application in full.

# At the load balancer: cap NEW connections per second per backend.
limit_conn_zone $server_name zone=ws:10m;
limit_req_zone  $binary_remote_addr zone=wsconn:10m rate=5r/s;

Verified by replaying the same deploy in a load test:

                              before      after
clients disconnected per
  rolled server               ~5,700      ~5,700 (unchanged: they must move)
reconnect window              ~2 s        ~90 s (drained)
peak reconnects/s             ~17,000     ~190
auth service p99              40 s        41 ms
Redis CPU during a deploy     100%        11%
servers failing liveness      54 of 60    0
deploy outcome                outage      no user-visible impact

The connection count did not change and the rate did. That is the whole lesson: a WebSocket platform's capacity has two numbers, concurrent connections and connection establishment rate, and the second is what deploys and network blips test.

The steady-state tuning done afterwards:

                              before      after
connections per server        5,700       14,000
memory per server             12 GB       9 GB       (socket buffer tuning)
servers                       60          26
ulimit -n                     65,536      1,048,576
tcp_rmem default              87,380      16,384
ephemeral ports at the LB     default     widened + 4 backend ports

Twenty-six servers instead of sixty, from socket buffer defaults and file descriptor limits, once the reconnect behaviour was safe enough to consolidate.

Production evidence

Slack, Discord and Figma have all published on WebSocket scale, and the recurring themes are the same three: consistent hashing for connection placement, a purpose-built backplane rather than broadcast-to-everything, and reconnect behaviour as the dominant operational concern. Discord's published work on Elixir and later Rust for their gateway is largely about the fan-out problem.

Redis Pub/Sub's single-threaded delivery is documented, and it is why Redis Cluster's pub/sub broadcasts to all nodes (which does not help) and why sharded pub/sub was added in Redis 7.0 to confine a channel to one shard.

Socket.IO's Redis adapter implements the broadcast-to-everything model by default, which is why Socket.IO deployments hit the backplane limit at a few dozen servers, and why the sharded and cluster adapters exist.

The full-jitter backoff formula is from AWS's "Exponential Backoff And Jitter" article, which measured that random(0, base) disperses a herd substantially better than base + random(0, jitter), and the difference is largest exactly in the reconnect-storm case.

Kubernetes' terminationGracePeriodSeconds and preStop are the mechanism for draining, and the WebSocket case is the clearest illustration of why the grace period must exceed the drain window: the same argument as the kube-proxy page, with a much longer window.

Cloudflare's and Fastly's documentation on WebSocket support both note the connection duration limits imposed by their infrastructure, which is a constraint worth knowing: an intermediary that closes idle connections at 100 seconds makes application-level pings mandatory rather than optional.

The debate

Sticky routing or a backplane? Both, and framing it as a choice is the error. Sticky routing solves "this client's messages reach a consistent process"; a backplane solves "a message produced anywhere reaches the process holding the recipient." A system with only sticky routing cannot fan out; a system with only a backplane still needs the client's own messages to land somewhere consistent for any per-connection state.

Which backplane? Redis Pub/Sub for ephemeral state (presence, typing indicators, cursor positions) where a missed message is invisible, and it is single-threaded so it becomes a one-core limit. Redis Streams or NATS JetStream when a missed message matters. Kafka when the messages are also a business event stream and you want replay. The mistake is defaulting to Redis Pub/Sub for messages that must not be lost, because its failure mode (a restarting subscriber misses everything sent while it was down) is invisible in testing.

Should every server subscribe to everything? No, and this is the change that most often unblocks scale. Broadcast-to-all means the bus carries messages x servers, so it becomes the limit at a few dozen servers. Channel-per-topic with dynamic subscribe and unsubscribe costs subscription churn and reduces delivery volume by the fan-out factor: 98 percent in the worked example. The trade is bus CPU against subscription-management complexity, and above about 20 servers the first dominates.

How do you deploy without disconnecting everyone? You cannot avoid disconnecting the clients on a replaced instance; you can control the rate. Draining over 60 to 90 seconds with close code 1001 turns an instantaneous storm into a normal reconnect rate, and it requires terminationGracePeriodSeconds to exceed the window. The alternative sometimes proposed, connection migration, is not realistic for most stacks: it requires transferring socket state between processes and the complexity dwarfs the benefit.

Are WebSockets the right choice at all? Frequently not. For server-to-client streaming only, SSE is simpler: it is plain HTTP, it reconnects and resumes automatically via Last-Event-ID, it works through every proxy, and it has no upgrade handshake. See SSE vs WebSockets. WebSockets earn their place when the client needs to send frequently on the same connection, which is collaborative editing, gaming and bidirectional protocols, and not most notification use cases.

What should you measure? Concurrent connections and connection establishment rate, and the second is the one that is usually not on a dashboard. Steady-state capacity planning misses the failure entirely, because a platform comfortable at 340,000 connections went down at 17,000 new connections per second. Add reconnect rate, close codes by category, and time to drain during a deploy.

Follow-up Q&A

"How do you scale WebSockets across many servers?"

Two mechanisms for two problems. Sticky routing, ideally consistent hashing on a client key rather than ip_hash, so a client's connection and any per-connection state stay on one process, and so adding a server moves 1/N of clients rather than most of them. And a pub/sub backplane so a message produced on any server reaches the process holding the recipient. You need both: sticky alone cannot fan out, and a backplane alone still leaves per-connection state scattered.

"Why not ip_hash?"

It breaks with carrier-grade NAT, where thousands of mobile clients share one source IP and land on one server. It rebalances almost everything when the server set changes, and on a WebSocket service every rebalance is a disconnect: plain hashing moves about 75 percent of clients when going from 3 servers to 4, consistent hashing moves about 25. And it gives you no control over the key, so you cannot pin by tenant or by document.

"What breaks first when you scale a WebSocket service?"

Usually file descriptors, because the default ulimit -n is 1024. Then ephemeral ports at the load balancer, which are about 28,000 per backend ip:port tuple, so you widen the range or add backend ports. Then socket buffer memory, because the defaults are sized for throughput per connection rather than connection count: tuning tcp_rmem/tcp_wmem defaults took one service from 12 GB to 9 GB per server at the same connection count. And then the backplane, if every server subscribes to everything.

"What is the reconnect storm and how do you prevent it?"

When a server is replaced, all its clients reconnect at once, and naive clients retry immediately on failure, so a failed reconnect feeds the storm. In one case a routine deploy produced 17,000 reconnects per second, saturated the auth service, caused healthy servers to fail liveness probes, and became a 29-minute outage. The fixes are client-side full-jitter backoff (random(0, base), not base + random), server-side draining that spreads closes over 60 to 90 seconds with code 1001, and making the reconnect path cheap: local JWT verification instead of a database lookup.

"Why is full jitter better than adding jitter to a base delay?"

Because base + random(0, jitter) still has every client waiting at least base, so the herd is delayed rather than dispersed and arrives as a slightly wider spike. random(0, base) spreads clients uniformly across the whole interval. AWS measured the difference and the effect is largest exactly in this scenario, where a large population fails and retries simultaneously.

"When would you use SSE instead?"

When the traffic is server-to-client only, which covers most notification, feed and streaming cases. SSE is plain HTTP, so it works through every proxy without an upgrade, and it reconnects and resumes automatically via Last-Event-ID, which is the reconnect handling you would otherwise write yourself. WebSockets earn their place when the client sends frequently on the same connection: collaborative editing, gaming, bidirectional protocols.

Common misconceptions

"Sticky routing or a backplane." Both, for different problems. Sticky keeps a client's connection and state on one process; the backplane gets messages to whichever process holds the recipient.

"ip_hash is sticky routing." It is the weakest form: it breaks under carrier-grade NAT, it rebalances most clients when the server set changes, and every rebalance is a disconnect.

"WebSocket capacity is concurrent connections." It is two numbers, and the second (establishment rate) is what deploys and network events test. A platform comfortable at 340,000 connections failed at 17,000 new connections per second.

"Redis Pub/Sub is a message queue." It is fire-and-forget with no persistence: a subscriber that is down misses everything sent while it was down, permanently, and the failure is invisible in testing.

"A rolling deploy is safe because it is gradual." It is gradual in servers and instantaneous in disconnections: replacing one server of 60 severs all of its connections at once. Draining spreads the reconnects; the deploy pace does not.

Interview delivery note

Say this verbatim: "A WebSocket platform has two capacity numbers, concurrent connections and connection establishment rate, and the second is the one nobody dashboards. A system comfortable at 340,000 connections went down at 17,000 new connections a second during a routine deploy, because the clients had no backoff and the server severed connections instead of draining." Two numbers, and the specific failure the second one predicts.

The senior-versus-staff separator is full jitter versus jittered backoff. A senior engineer adds exponential backoff. A staff engineer specifies random(0, base) rather than base + random(0, jitter), because the second form still has every client waiting at least base, so it delays the herd rather than dispersing it. That is a one-line difference that determines whether backoff works at all in the reconnect-storm case.

The second signal is treating the drain window as a deploy parameter. Saying "spread closes over 90 seconds with code 1001, and terminationGracePeriodSeconds must exceed that or Kubernetes kills the process mid-drain" shows you have connected the WebSocket lifecycle to the orchestrator's, which is where the practical failure is.

Further reading

  • AWS Architecture Blog, "Exponential Backoff And Jitter," for the measured comparison of backoff strategies under a synchronised failure.
  • Redis documentation on Pub/Sub delivery semantics and sharded pub/sub (Redis 7.0), for the fan-out and single-thread constraints.
  • Discord's engineering posts on their gateway, for backplane fan-out at scale.
  • The WebSocket RFC (6455) on close codes, particularly 1001 (going away) and its intended use during server shutdown.