Designing a WebSocket Gateway: 100 Million Persistent Connections
Stateless HTTP scaling has one rule: any server can handle any request. WebSockets repeal it. A connection lives on exactly one machine for minutes or hours, holds kernel and application state there, and everything that wants to reach that user must find that machine. Every chat app, live dashboard, multiplayer game, and notification system contains this system — a connection gateway — and it has almost nothing in common with the stateless tier behind it. Designing it well is a distinct skill, and interviewers increasingly carve it out as its own question.
Requirements
Hold 100M concurrent connections; deliver a message to any connected user in under 100ms; survive node failures and — harder — routine deploys; handle reconnect storms after regional blips; auth, heartbeats, and per-connection backpressure.
Per-node limits: the real ones
The mythical 65K-port ceiling is irrelevant (that limit applies per client source-IP tuple; a server accepts millions on one port). Actual per-node budgets: memory (~tens of KB per idle connection across kernel buffers + TLS session + app object — 1M connections ≈ 30-50GB before message buffers), file descriptors (a config line), and event-loop discipline (epoll/kqueue-based runtimes — Netty, Go, Erlang — not thread-per-connection). Practical dense nodes run 500K-1M connections; 100M users therefore means 100-200 gateway nodes, and the interesting problems are all fleet-level.
The routing problem: who holds user X?
A message for user X must reach the node holding X's socket. Two workable shapes:
Connection registry (the common one): on connect, gateway writes user → node to a shared store (Redis/etcd) with a TTL refreshed by heartbeat; senders look up and RPC the owning node directly.
connect: user X -> LB -> gateway-17; registry[X] = {node:17, ts}
send: lookup X -> gateway-17 -> push down socket
failover: node dies -> TTLs lapse -> clients reconnect elsewhere -> registry heals itself
Pub/sub fabric (the decoupled one): every gateway subscribes to topics for its connected users (user.X); senders publish blindly. No registry to keep correct — the broker's subscription table is the registry — at the cost of a broker tier sized for your entire message volume. Hybrid is common: registry for point-to-point, pub/sub for broadcast/rooms.
Multi-device is free in either shape (registry holds a set; or both devices' gateways subscribe), and it forces the semantic question worth raising unprompted: delivery to user means fan-out to all their sockets, dedupe client-side by message ID.
The two storms
Deploys. Restarting a gateway disconnects a million users — so a naive rolling deploy of 150 nodes disconnects everyone, hourly if you ship hourly. The playbook: drain mode (stop accepting new connections; existing ones get a RECONNECT_SOON frame with a jittered deadline), clients reconnect gracefully elsewhere at their leisure, node exits when empty or at timeout. Long-lived connections turn "deploy" from an instant into a process — budget 10-15 minutes per batch and design the fleet math around perpetual partial drain.
Reconnect stampedes. A regional blip drops 20M connections; all 20M phones retry now — plus TLS handshakes (the expensive part) plus auth checks plus registry writes, a self-inflicted DDoS arriving exactly at recovery. Defenses in order of leverage: client-side exponential backoff with jitter baked into the SDK (the single highest-value line of code in this system), server admission control (accept-rate limiting; shed excess with Retry-After), TLS session resumption to cheapen handshakes, and auth-token validation that skips the identity service on reconnect (short-lived signed tokens verified locally).
Semantics: the connection is not the delivery
The socket delivers at-most-once: anything in flight when a connection drops is gone. Real delivery guarantees live one layer up — per-user sequence numbers and a short replay buffer (or a durable per-user queue for messaging products): client reconnects with last_seq=1041, gateway (or the message service behind it) replays the gap. This split — dumb pipe, smart resume protocol — keeps gateways stateless-enough to drain and crash freely; the resumable state lives in a store sized for undelivered messages, not all traffic.
Per-connection backpressure closes the loop: a phone on 2G can't absorb a 10K msg/sec room. Bounded per-socket send queues; on overflow, policy by product — drop-oldest with a "you're behind" marker (dashboards, tickers), conflate to latest-state (presence, cursors — the leaderboard trick again), or disconnect-and-resume (messaging). Unbounded queues here are how one slow client OOMs a node holding a million healthy ones.
Heartbeats run at two levels — TCP keepalive is insufficient (NAT/LB idle timeouts, typically 60-350s, kill silent connections; and a dead peer can take kernel-minutes to surface) — so the protocol pings every ~30s, which doubles as the registry TTL refresh and the liveness signal for "deliver vs queue" decisions.
| Interview probe | Answer sketch |
|---|---|
| Why not sticky sessions on the LB? | Stickiness solves routing in, not reaching the user from inside — the registry/pub-sub problem remains; and stickiness fights draining |
| Registry entry stale (node died with TTL unexpired)? | Send RPC fails fast → treat as offline → queue + push notification path; TTLs bound the lie to seconds |
| One user in 10M-subscriber room? | Rooms are pub/sub topics; per-gateway single delivery + local fan-out to that node's members — never per-user publishes |
| WebSocket vs SSE vs long-poll? | Bidirectional + binary + one FD → WS; server-push-only telemetry → SSE is operationally simpler (plain HTTP); long-poll is the fallback, not the design |
The keep-this shape: a fleet of dense, drainable socket-holders; a registry or broker answering "who holds X"; sequence-numbered resume one layer up; and jittered backoff in every client you ship. Get those four right and the other hundred details are tuning.
Keep reading
Write Skew: The Anomaly Snapshot Isolation Does Not Catch
Two transactions each read what the other writes, both commit, and an invariant dies. Snapshot isolation allows write skew — here is how to close the hole.
Designing a Shopping Cart: Price Snapshots, Merge, and No Inventory Hold
A cart is a per-user document, not a reservation. What to store on the line, how to merge an anonymous cart at login, and when the price is allowed to change.
Designing Snowflake-Style IDs: Ordering, Workers, and Clock Rollback
A 64-bit id from a timestamp, a worker number, and a per-millisecond sequence. How many you can issue, why the clock must not step backwards, and why JavaScript cannot hold one.
PostgreSQL 19 on October 29: WAIT for Read-Your-Writes, REPACK Without the Lock, and What Got Pulled
PostgreSQL 19 GA is scheduled for October 29, 2026. The WAIT command gives standbys read-your-writes, REPACK CONCURRENTLY replaces VACUUM FULL and CLUSTER, autovacuum goes parallel, and SQL/PGQ, online checksums, and FOR PORTION OF were reverted in Beta 4.
Idempotency Keys for Write APIs
How to make POST safe to retry: the key, the stored response, the race between two identical requests, and the expiry that forgets too soon.
Read-Your-Writes: The User Just Saved and the Read Replica Does Not Know
Why a write to the primary followed by a read from a replica shows stale data, and the practical ways to pin the next read without sending all traffic to the primary.
Newsletter
New posts, straight to your inbox
One email per post. No spam, no tracking pixels, unsubscribe anytime.
Comments
- No comments yet. Be the first.