Preface#
On 25 March 2026, at 12:13 PDT, Discord deployed a routine change to one zone of its session service: bigger pods, fewer of them. Kubernetes terminated half of that zone's pods. The service was meant to hand its in-memory sessions to other nodes first, but a safety check made it wait, and the termination grace period ran out. 17% of all sessions on Discord stopped at once (Discord Engineering, April 2026).
The sessions service runs in three equally balanced zones, so this was half of one zone. Every client did what it was designed to do and reconnected. This is what followed:
- 12:13 PDT · The change reaches the first zone
- Half the session pods in us-east1-b stop before handing off: 17% of sessions gone
- Immediately · The gateway tells clients to reconnect
- The start-session rate limit, not re-tuned after a migration, lets too many through. Gateway memory spikes and the zone's gateways run out of memory
- Then · A second wave
- The OOM kills the sockets of users whose sessions were fine, and they reconnect too, failing over to the region's other two zones
- Until 15:30 PDT · The blast radius moves
- The flood reaches the voice control plane, and calls show "Awaiting Endpoint" for over three hours
A deploy became a reconnect storm, and the storm took down healthy connections. The trigger wasn't a surge in messages: it was 17% of Discord's persistent sessions trying to come back at the same moment.
Yesterday the platform got a durable delivery pipeline, where "delivered" means handing the notification to a provider. In-app is different: we hold an open connection to every online user and push down it ourselves. Today we become the provider, with millions of sockets and their storms.
Day 3 makes the platform the provider. Discord's March 2026 deploy stopped 17% of sessions and set off a reconnect storm. Four transports (short and long polling, SSE, WebSockets) are chosen per surface. The gateway tier is sized by connections: ~333,000 heartbeats a second against ~1,700 in-app notifications, 200,000 sockets per node, 75 nodes in three zones, with one-time connect tickets and in-band reauth. A registry with a lease and epoch per gateway, one pub/sub channel per gateway and the inbox written first let a client resume after seq 1183 when gw-17 dies, with push if no ack comes. Heartbeats every 25–30 s beat the shortest idle timeout; a lost zone's ~3.3M reconnects need full jitter, admission control and slow drains.
Part I: Foundations#
Chapter 1: Four Ways to Push to an Online User#
1.1 The Simple Definition#
A real-time channel lets the server send data when something happens, not when the client asks. HTTP works the other way round.
Our running example is the week's fintech super-app: 50M users, 500M notifications a day, 10% of them in-app (Day 1's channel mix). Priya has the app open. Arjun sends her ₹2,400. We want "₹2,400 received from Arjun" on her screen in under a second and the bell's badge to tick from 3 to 4.
1.2 Polling and Long Polling#
Short polling asks every few seconds: "anything new?" It passes every proxy, but latency averages half the interval and almost every answer is empty: 7.5 million users polling every 5 seconds is 1.5 million requests a second to deliver a few thousand notifications.
Long polling asks, and the server holds the request until there's something to send or ~30 seconds pass. Latency drops to near zero, but every delivery costs a full request cycle. It's the fallback that works wherever plain HTTP works.
1.3 Server-Sent Events (SSE)#
SSE is a browser API (EventSource) over one long-lived HTTP response in text/event-stream format:
id: 1184
event: notification
data: {"title": "₹2,400 received from Arjun", "unread": 4}
: keep-alive comment, ignored by the clientWhat makes it more than a long response (WHATWG HTML standard):
- Automatic reconnect, after a delay the server can set with a
retry:line. - Resume built in: on reconnect the browser sends
Last-Event-ID, so the server sends only what was missed. - Plain HTTP: it passes proxies that understand HTTP. The spec suggests a comment line "every 15 seconds or so" because legacy proxies drop idle connections.
The limits: server-to-client only, text only, and no custom headers from the browser API. Over HTTP/1.1, each stream takes one of the browser's connections to the origin, and MDN notes that limit is 6 per browser and domain, across all tabs.
1.4 WebSockets#
A WebSocket starts as an HTTP request with Upgrade: websocket. After 101 Switching Protocols, the TCP connection carries full-duplex frames in both directions, binary or text, with ping and pong control frames (RFC 6455). Slack: "Every Slack client has a persistent websocket connection to Slack's servers to receive real-time events" (Slack Engineering, 2023). Discord's gateway is "both the ingress and egress point for all websocket traffic" (Discord Engineering, 2026).
The cost: after the handshake it isn't HTTP, so every proxy must support it, and reconnect and resume are yours to design.
1.5 What HTTP/2 and HTTP/3 Change#
| HTTP/1.1 | HTTP/2 (TCP) | HTTP/3 (QUIC) | |
|---|---|---|---|
| SSE | One TCP connection per stream, 6-per-origin cap | A stream on a shared connection; servers should allow at least 100 (RFC 9113) | Same |
| WebSockets | Upgrade takes a whole TCP connection |
One stream via Extended CONNECT (RFC 8441), if every hop supports it | Same, per RFC 9220 |
| A lost packet | Stalls that connection | Stalls every stream on it | Stalls one stream |
| Wi-Fi to 4G | Connection dies | Connection dies | Connection IDs let it "survive changes to endpoint addresses" (RFC 9000) |
So HTTP/2 removes SSE's worst limit, while most WebSockets still upgrade over HTTP/1.1, one TCP connection each. Plan capacity in TCP connections.
1.6 Choosing#
| Short polling | Long polling | SSE | WebSockets | |
|---|---|---|---|---|
| Direction | Client pulls | Client pulls, server holds | Server → client | Both ways |
| Reconnect and resume | Not needed | Client loop | Built in | You build it |
| Proxies | Always | Always | Where HTTP works | Needs Upgrade support |
| Best fit | Rare updates | Last-resort fallback | Feeds, notification bells, dashboards | Chat, collaboration, games, native apps |
A notification platform's traffic is mostly server-to-client: SSE's shape. WebSockets win when clients talk a lot too, or you want one binary protocol everywhere.
Pick a transport, three times
Three surfaces: (1) the web dashboard's bell, open in several tabs; (2) the mobile apps in the foreground, where you want per-message acks and a compact binary protocol; (3) a bank partner's console inside a network whose proxy strips Upgrade headers. Choose a transport for each.
One strong answer
- Web bell: SSE over HTTP/2. One-way traffic, free reconnect and
Last-Event-IDresume, and HTTP/2 lifts the 6-connection cap across tabs. Acks go as ordinary POSTs. - Mobile apps: WebSockets. Two-way traffic (acks, heartbeats) and framing you control. The app owns its reconnect logic anyway.
- Corporate console: SSE, with long polling as the last resort. SSE is plain HTTP, so it survives a proxy that blocks upgrades, given comment keep-alives under the proxy's idle timeout.
Chapter 2: The Gateway Tier#
2.1 Why a Separate Tier#
Everything except short polling means the server holds state per online client. Stateless API servers deploy ten times a day and autoscale freely; restarting a connection holder disconnects every client on it at once. Discord notes that "traditional autoscaling methods don't work well" for its long-lived gateway connections (Discord Engineering, 2024).
So sockets get their own tier, the connection gateway: terminate, authenticate, keep alive, forward events. No business logic on the hot path, so it rarely needs deploying.
2.2 What One Connection Costs#
Each connection holds a file descriptor, kernel socket memory (tcp_rmem and tcp_wmem are limits that autotuning moves between a guaranteed 4 KB minimum and a maximum, per the kernel docs; an idle socket with nothing queued uses little of them), TLS state, and application state: ids, a bounded outbound queue, timers, and any compression context. Discord found "the bulk of the memory used by a gateway process is the zstandard streaming context" it keeps per connection (Discord Engineering, 2024).
Measure yours. For scale: in January 2012 WhatsApp showed one FreeBSD server with 2,277,845 open sockets, 35 GB of memory active and 18 GB wired (WhatsApp, 2012): roughly 15 KB of active memory per connection, or ~23 KB counting the wired kernel memory too (our arithmetic).
2.3 File Descriptors and epoll#
One thread per connection is impractical at 200,000 connections: 200,000 stacks and constant context switching. Gateways use event-driven I/O: on Linux, epoll lets a few threads ask which sockets are ready and handle only those. It "scales well to large numbers of watched file descriptors", at about 160 bytes of kernel memory each on 64-bit (epoll(7)).
The limits you hit first are configuration:
- Open files. The usual default soft
RLIMIT_NOFILEis 1,024; raise it above your target.fs.nr_opendefaults to 1,048,576 (kernel docs). Slack's 4 January 2021 outage worsened when its provisioning service hit "the Linux open files limit" (Slack Engineering, 2021). - Ephemeral ports behind a proxy. An L7 proxy that opens its own upstream connection per client needs a source port for each. Linux's default range, 32,768–60,999, gives ~28,000 per proxy IP to each gateway IP and port. Add IPs or ports, or use an L4 load balancer that passes connections through.
2.4 Sizing the Fleet#
What dominates the gateway's work?
10 million connections at the evening peak, each client sending a heartbeat every 30 seconds. In-app is 10% of 500M notifications a day. Per second at peak, which is bigger, heartbeats or in-app notifications, and by how much? What should the fleet be sized by?
One strong answer
- Heartbeats: 10M ÷ 30 s ≈ 333,000 a second.
- In-app notifications: 50M a day ≈ 580 a second; at Day 1's 3× peak, ~1,700 a second.
- So heartbeats outnumber notifications more than 100 to 1. Size the gateway by connections (memory, descriptors, how fast they can come back), not message throughput.
| Value | |
|---|---|
| Users (Week 6 spec) | 50M |
| Online at evening peak (app on screen or web tab open) | 15% → 7.5M |
| Connections per online user | ~1.33 → ~10M connections |
| Memory per connection, all-in (assumed; measure yours) | ~40 KB → ~400 GB |
| Node: 64 GB RAM, half for connection state | ~800K connections by memory |
| Connections per node, capped for blast radius | 200,000 → 50 nodes |
| Three zones, survive losing one | 50 × 1.5 = 75 nodes |
| Heartbeats at 30 s | ~333,000/s, ~4,400/s per node |
| In-app notifications (Day 1: 10% of 500M/day) | ~580/s average, ~1,700/s at Day 1's 3× peak |
| One zone lost | ~3.3M sockets reconnect |
| Admissions per node (TLS, auth, registry write, resume; assumed) | ~1,000/s → 50 survivors ≈ 50,000/s |
| Reconnect time, spread evenly | 3.3M ÷ 50K ≈ ~67 s |
| If every client retries within 1 s | 3.3M/s offered, ~66× capacity |
Conclusion: memory alone would need only 13 nodes (400 GB ÷ 32 GB); we run 75. The fleet is sized by blast radius and reconnect admission: a 200K cap keeps one node's failure to 2% of connections, and a zone loss is survivable only if clients spread reconnects over at least a minute.
Part II: Delivering to the Right Socket#
Chapter 3: Routing a Message to Its Gateway#
3.1 The Problem#
Priya's phone is on gateway gw-17, one of 75. The delivery service holding her notification runs elsewhere. How does the message find gw-17?
- Broadcast to every gateway: fine for 5; at 75, each node drops 74 of every 75 messages.
- Sticky placement: hash the user id onto a ring of gateways, as in Week 1, Day 1 · Partitioning.
- A connection registry: clients land on any gateway, which records where each connection is.
Sticky placement by hash
- No lookup on the hot path
- Clients must be steered to a specific gateway
- A dead node's clients pile onto one successor
- Multi-device users get awkward
Connection registry
- One sub-millisecond lookup per delivery
- Clients use the nearest healthy gateway
- A dead node's clients spread across the fleet
- Each device is its own entry
The registry is the usual choice. Slack uses consistent hashing for its channel servers, while clients connect to a Gateway Server "in its nearest region" (Slack Engineering, 2023).
3.2 Keeping the Registry Honest#
conn:{user_id} → { "phone-8f2c": "gw-17|epoch 9", "web-a1d0": "gw-42|epoch 3" }Its hard problem is staleness: the registry says gw-17, but gw-17 died nine seconds ago. Refreshing every entry's TTL on each heartbeat costs ~333,000 writes a second. Cheaper:
- Liveness per gateway. Each gateway holds a lease
gw:17:alive(15-second TTL) and an epoch that increases on every process start. Lookups ignore entries with a dead lease or an old epoch. 75 lease refreshes replace 333,000. - Clean up on close, sweep the rest. Crash leftovers are ignored at read time and swept lazily.
This is routing state, not presence. Showing green dots to other users is tomorrow's problem.
3.3 The Pub/Sub Layer#
Each gateway subscribes to one channel, its own (gw:17), not millions of per-user channels. The delivery service publishes to the channel of each gateway holding one of the user's connections. Two facts about Redis Pub/Sub (Redis docs):
- It's at-most-once. A message published while
gw-17restarts is gone. - Classic Pub/Sub in a cluster sends every message to every node. Sharded Pub/Sub (
SPUBLISH, Redis 7.0+) keeps a channel within its shard.
At-most-once is fine only because the socket isn't the record. The notification was written to the durable inbox before anyone was told. The socket message is a fast hint; the inbox is the truth.
%% title: The in-app real-time path: from the router to a socket
flowchart TD
RT["Notification router<br/>(Day 1 and Day 2)"]:::service --> DS["In-app delivery service<br/>inbox write, per-user seq"]:::core
DS --> IB@{ icon: "sd:database", label: "In-app inbox<br/>durable, seq per user" }
DS --> REG@{ icon: "tech:redis", label: "Connection registry<br/>user → gateway, epoch" }
DS --> PS@{ icon: "sd:stream", label: "Sharded pub/sub<br/>one channel per gateway" }
PS --> G1["Gateway gw-17<br/>≤200K sockets"]:::core
PS --> G2["Gateway gw-42<br/>≤200K sockets"]:::core
P@{ icon: "sd:phone", label: "Mobile app<br/>WebSocket" } --> LB@{ icon: "sd:load-balancer", label: "L4 load balancer<br/>any healthy gateway" }
W@{ icon: "sd:browser", label: "Web app<br/>SSE over HTTP/2" } --> LB
LB --> G1
LB --> G2
DS -->|"no socket, or no ack in 5 s"| PW["Push worker<br/>(Day 2)"]:::service
PW --> APNS@{ icon: "tech:apple", label: "APNs" }
PW --> FCM@{ icon: "tech:firebase", label: "FCM" }
class P,W client
class LB edge
class REG cache
class PS queue
class IB store
class APNS,FCM external3.4 Sequence Numbers and Acks#
Each in-app notification gets a per-user sequence number when written to the inbox, and the client acks the highest number it has processed:
- Resume is a range query. A reconnecting client says "after 1183" and the gateway reads later items from the inbox. No replay buffer, so a gateway can die without losing anything.
- Gaps are visible. Receiving 1185 after 1183 means one was missed; the client fetches it.
- Acks drive fallback. No ack within a few seconds means the socket push probably failed: fall back to mobile push (Chapter 6).
Discord's protocol works the same way: a resuming client gets "the missed events in order" (Discord Developer Docs). Discord keeps them in a session process that outlives the socket; we keep them in the inbox we already had.
%% title: Connect, authenticate, resume, deliver
sequenceDiagram
participant C as Priya's phone
participant G as Gateway gw-17
participant A as Auth service
participant R as Registry
participant I as Inbox
C->>G: WebSocket upgrade with a one-time connect ticket
G->>A: verify ticket
A-->>G: user u42, device phone-8f2c, token valid 15 min
G->>R: u42 phone-8f2c at gw-17, epoch 9
C->>G: hello, resume after 1183
G->>I: items for u42 after 1183
I-->>G: none missed
G-->>C: live
loop every 30 s
C->>G: ping
G-->>C: pong
end
Note over G,C: the delivery service publishes to channel gw-17
G-->>C: seq 1184, 2400 rupees received from Arjun
C->>G: ack 11843.5 The Gateway Connection, in Code#
# gateway/connection.py
"""
One client connection on a gateway node (illustrative, asyncio).
- The outbound queue is bounded: a slow client is disconnected with a
resumable code instead of being buffered until the node runs out of memory.
- Silence longer than IDLE_TIMEOUT means the connection is dead, whatever TCP thinks.
- Nothing here is the record: missed events are re-read from the inbox on resume.
"""
import asyncio, json, time
IDLE_TIMEOUT = 75 # seconds: 2.5 missed client heartbeats at 30 s
MAX_QUEUE = 256 # frames waiting for a slow client
CLOSE_RESUMABLE = 4008 # our code: "reconnect and resume", not "log in again"
CLOSE_IDLE = 4009
class Connection:
def __init__(self, ws, user_id, device_id, gateway):
self.ws, self.user_id, self.device_id, self.gw = ws, user_id, device_id, gateway
self.queue = asyncio.Queue(maxsize=MAX_QUEUE)
self.last_heard = time.monotonic()
def offer(self, event: dict) -> bool:
"""Called by the gateway's pub/sub reader. Must never block it."""
if event.get("coalesce_key"): # e.g. "unread_badge": keep only the newest
self.drop_queued(event["coalesce_key"])
try:
self.queue.put_nowait(event)
return True
except asyncio.QueueFull: # slow consumer: protect the node
asyncio.create_task(self.close(CLOSE_RESUMABLE))
return False
async def writer(self):
while True:
event = await self.queue.get()
await self.ws.send(json.dumps(event)) # backpressure stops here, not in the node
async def reader(self):
async for raw in self.ws:
self.last_heard = time.monotonic()
msg = json.loads(raw)
if msg["op"] == "ping":
await self.ws.send('{"op":"pong"}')
elif msg["op"] == "ack":
await self.gw.acks.record(self.user_id, self.device_id, msg["seq"])
elif msg["op"] == "reauth": # fresh token before the old one expires
await self.gw.extend_auth(self, msg["token"])
async def watchdog(self):
while True:
await asyncio.sleep(5)
if time.monotonic() - self.last_heard > IDLE_TIMEOUT:
await self.close(CLOSE_IDLE)
returnThe important lines refuse work: a full queue or silence closes the socket, and the inbox keeps whatever was missed.
The gateway that wasn't there
At 21:04:10, gw-17 crashes. Its lease has a 15-second TTL. At 21:04:16 the delivery service looks up Priya, finds gw-17|epoch 9 with the lease still alive, and publishes to gw:17. What happens to that notification, and what makes sure she still gets it?
One strong answer
- The publish is lost: nobody is subscribed to
gw:17, and Pub/Sub is at-most-once. - The inbox saves it: seq 1184 was written first. Her app lost its socket at 21:04:10, reconnects with jitter to another gateway, and resumes "after 1183", which returns 1184.
- The ack timeout covers the gap: if she isn't back within the window, no ack arrives and the delivery service falls back to mobile push.
Chapter 4: Heartbeats and Dead Connections#
4.1 TCP Doesn't Tell You#
An idle TCP connection sends nothing. If Priya's phone enters a lift, or a carrier NAT forgets the mapping, neither end knows until it sends. The server keeps a "live" socket, the registry points at it, and pushes vanish. TCP keepalive won't help: Linux's default tcp_keepalive_time is 2 hours (kernel docs). You need application heartbeats.
4.2 Every Box on the Path Has a Timeout#
| Box | Idle timeout | Source |
|---|---|---|
| Carrier NATs and firewalls | 11 of 73 carriers cut idle TCP in under 10 minutes, 4 in 5 or less, one at 255 seconds | Wang et al., SIGCOMM 2011 |
| The NAT standard | "MUST NOT be less than 2 hours 4 minutes" | RFC 5382 |
| Legacy HTTP proxies | Short enough that SSE sends a comment "every 15 seconds or so" | WHATWG HTML |
| Your load balancer | AWS ALB: 60 s by default, and HTTP/2 PING frames don't reset it | AWS docs |
The heartbeat must be shorter than the shortest idle timeout on the path: usually your own load balancer, then the worst carrier.
4.3 Choosing the Interval#
Shorter heartbeats find dead connections sooner but cost battery: the same study estimated that keep-alives under timeouts below 10 minutes "could use more than 10% of battery per day" compared with a 30-minute timeout (Wang et al., 2011). A workable default for a foreground app:
- Client heartbeat every 25–30 seconds, under a 60-second load balancer timeout, with jitter. Discord has clients wait
heartbeat_interval * jitterbefore the first beat so they don't reconnect "at the exact same time"; its docs' example interval is 45 seconds (Discord Developer Docs). - Server closes after ~2.5 missed beats (75 s) and deletes the registry entry.
- Client reconnects after one missed pong. Discord's docs call that a "zombied" connection: terminate it and resume.
- The client drives. It knows when it's on screen or about to be suspended.
For SSE, send a : comment every 15–30 seconds. Set the timeouts you own above the heartbeat, as in Week 2, Day 1 · Timeout Hell.
4.4 Mobile Networks#
Switching from Wi-Fi to 4G changes the phone's IP address, and every TCP connection on the old address dies without the server being told. Clients should treat every OS network-change event as a reconnect trigger. HTTP/3's connection migration can carry a QUIC connection across the change. And once an app is backgrounded, mobile operating systems suspend it soon after, socket included: that's where mobile push takes over (Chapter 6).
A five-minute heartbeat
To save battery, a teammate sets the mobile heartbeat to 5 minutes and the server's idle timeout to 12. Users on one carrier report "notifications only show up when I reopen the app". What's happening, and what do you change?
One strong answer
- The carrier NAT drops the idle mapping first (some at 255 s). Both ends still think the socket is open.
- The server routes to a dead socket for up to 12 minutes; nothing errors.
- On reopen, the client reconnects and resumes from the inbox, so notifications "arrive late".
- Fix: heartbeat at 25–30 s with jitter, server timeout ~2.5 intervals, and ack timeouts that fall back to push.
Part III: When Connections Fail Together#
Chapter 5: Reconnect Storms, Backpressure and Deploys#
5.1 The Storm#
The danger is correlated disconnects: a gateway crash (up to 200K clients), a zone outage (3.3M), a deploy, a Monday morning. Slack on 4 January 2021: "client caches are cold and clients pull down more data than usual on their first connection" (Slack Engineering, 2021).
Each reconnect costs handshakes, auth, a registry write and a resume read. The fleet admits ~50,000 a second; 3.3M simultaneous retries offer 66 times that. Overloaded gateways fail health checks, drop more sockets, and the storm feeds itself, as in the Preface.
5.2 Shaping It From the Client#
- Full-jitter exponential backoff: a random wait between 0 and
min(cap, base × 2^attempt). A fixed delay just moves the wave. - Jitter the first attempt too, over a window the server can widen during incidents.
- Resume, don't reboot. "After seq 1183" costs one range read; a full boot costs far more. Slack built its Flannel edge cache partly because "reconnection storms are resource intensive", and serving reconnects from it "reduces impact of reconnect storms to the Slack backend servers" (Slack Engineering, 2017).
- Honour "retry after".
Jitter alone isn't enough. In Slack's February 2022 incident, clients already used "retries with exponentially increasing backoff periods and jitter", yet "automated retries still contributed to the load". What reduced the load was a server-side throttle on client boots, raised in small steps while caches refilled and an inefficient query was fixed (Slack Engineering, 2022).
# client/reconnect.py: the reconnect loop every client runs (illustrative)
import asyncio, random
BASE, CAP = 1.0, 60.0 # seconds
async def stay_connected(open_socket, state):
attempt = 0
while state.app_in_foreground:
hint = None
try:
ws = await open_socket(ticket=await state.fresh_connect_ticket())
await ws.send_json({"op": "hello", "resume_after": state.last_seq})
attempt = 0 # healthy again: reset backoff
async for event in ws:
state.apply(event) # dedupes by seq, detects gaps
await ws.send_json({"op": "ack", "seq": event["seq"]})
except ServerSaidRetryLater as e:
hint = e.retry_after
except (ConnectionLost, Timeout):
pass
delay = random.uniform(0, min(CAP, BASE * 2 ** attempt)) # full jitter
if hint:
delay = max(delay, hint + random.uniform(0, hint)) # respect pushback, still spread
attempt += 1
await asyncio.sleep(delay)5.3 Shaping It From the Server#
- Admission control on connect: a token bucket per node, plus a global one in front of auth and the registry. Over the limit, answer "retry after N s" at once. That's Week 1, Day 3 · Rate Limiting, applied to connections.
- Fail fast, don't queue. In 2017, "nearly 5,000,000 session processes" stampeded ten guild-registry processes at Discord; timed-out calls piled up until the VM ran out of memory. A semaphore that makes callers bail early fixed it (Discord Engineering, 2017).
- Re-tune limits when the deployment changes. Discord's 2026 start-session limit was per host and hadn't been re-tuned after the move from VMs to Kubernetes, which ran more pods than there had been VMs, so more starts got through in total (Discord Engineering, 2026).
- Keep boot small. One Discord
READYpayload was 2,517,725 bytes before compression (Discord Engineering, 2024). Send the minimum and let clients fetch the rest lazily.
5.4 Backpressure: The Slow Client#
A phone on a bad train connection reads at 2 KB/s while events arrive faster. The kernel send buffer fills, writes would block, and the excess must go somewhere. If that's an unbounded list, a few thousand slow clients run the node out of memory and drop every healthy client on it. Apply Week 3, Day 3 · Backpressure and Flow Control per socket:
- Bound every queue, in frames and bytes.
- Coalesce the replaceable: five "unread count" updates are worth the newest one.
- Shed the low-value first.
- Then disconnect with a resumable code. Losing a socket costs one resume; losing the node costs 200,000.
And never let one slow socket slow the fan-out: offer() above never waits.
5.5 Graceful Drain on Deploy#
Make a deploy look like a trickle of ordinary reconnects, never a crash.
%% title: Draining one gateway without a stampede
flowchart TD
A["Deploy selects gw-17"]:::core --> B["Fail readiness<br/>no new connections"]
B --> C["Send reconnect hints in batches<br/>e.g. 1% of sockets every 6 s"]
C --> D{"Sockets left?"}
D -->|"yes, before the deadline"| C
D -->|"under 1%, or the deadline hits"| E["Close the rest with a resumable code"]
E --> F["Release the lease, bump the epoch, stop"]:::ok- Drain at a set rate. At 1% every 6 seconds, 200K sockets drain in ~10 minutes, and other gateways absorb them as normal admissions.
- The grace period must outlast the drain. That's where Discord's 2026 outage began; its fix rejects a scale-down "until the pod has successfully handed off its processes" (Discord Engineering, 2026).
- One node per zone at a time, pausing if connect refusals climb.
- Change the proxy tier rarely. After each reload, Slack's HAProxy websocket tier kept old processes running "for many hours to allow long-lived websocket connections to drain"; Envoy can "hot restart itself without dropping any connections" (Slack Engineering, 2021).
%% title: A client connection's life stateDiagram-v2 [*] --> CONNECTING CONNECTING --> AUTHENTICATING: socket open AUTHENTICATING --> LIVE: ticket valid, resume done AUTHENTICATING --> BACKOFF: rejected or told to retry later LIVE --> LIVE: event with seq n, ack n LIVE --> BACKOFF: missed pong, network change, close 4008 LIVE --> DRAINING: server reconnect hint DRAINING --> CONNECTING: after a random delay BACKOFF --> CONNECTING: full-jitter delay elapsed LIVE --> [*]: app backgrounded, push takes over class LIVE ok class BACKOFF queue
The 2 pm deploy
A new gateway build restarts 25 of 75 nodes at once at 2 pm on a weekday, and clients reconnect after a fixed 2-second delay. Using the calc, predict the first minute, then name three changes that make the same deploy invisible.
One strong answer
- What happens: ~3.3M clients drop together and return together 2 s later, ~66× what 50 survivors can admit. Handshakes time out, health checks fail, healthy nodes shed sockets, and the wave repeats every 2 s.
- Fix 1: one node per zone at a time, each drained over ~10 minutes with randomised hints.
- Fix 2: full-jitter backoff with a jittered first attempt.
- Fix 3: connect admission with cheap "retry after" refusals, and resume from the inbox so each reconnect is light.
Chapter 6: In-App Real Time vs Mobile Push#
| In-app (our socket) | Mobile push (APNs / FCM) | |
|---|---|---|
| Works when | App on screen, or a web tab open | App backgrounded or closed |
| Ordering | Ours, by sequence number | "FCM doesn't guarantee the order of delivery"; APNs "may reorder notifications" |
| Offline storage | Our inbox | APNs stores one notification per app, usually the latest, for up to 30 days. FCM keeps up to 100 non-collapsible messages on Android, then discards them all and tells the app to sync; default lifespan four weeks |
| Control | Ours | The provider may throttle, batch or delay |
(Apple and Firebase docs.) The socket is for users who are looking; push is for users who aren't. Push is a doorbell, not a mailbox: the app reads the inbox when it opens.
A typical fallback policy:
- No live connection? Push at once, if preferences allow.
- No ack within a few seconds? Treat the socket as a zombie and push.
- Acked on one device? Skip push for routine events; security alerts go everywhere.
- Collapse replaceable events (
apns-collapse-id, FCM collapse keys).
# delivery/in_app.py: route one in-app notification (illustrative)
async def deliver(n):
seq = await inbox.append(n.user_id, n) # durable first (Day 2)
conns = await registry.live_connections(n.user_id) # drops dead-lease entries
for c in conns:
await pubsub.spublish(f"gw:{c.gateway_id}", {"user": n.user_id, "seq": seq, **n.payload})
acked = conns and await acks.wait_any(n.user_id, seq, timeout=n.ack_timeout_s)
if n.priority == "critical" or not acked:
await push_queue.enqueue(n, collapse_id=n.collapse_key) # APNs / FCM via Day 2's workers
return "acked" if acked else "pushed"Priya's ₹2,400
Priya has the web dashboard open (SSE connected, acks working). Her phone is in her bag, app backgrounded. Arjun's ₹2,400 arrives; a minute later, a "new login from Chrome on Windows" alert fires. Does the phone get a push for each?
One strong answer
- Payment: no push needed. The web client acked, so she's seen it. (Pushing money-in events anyway is a product preference, not a delivery fallback.)
- Security alert: push anyway. It's critical, it concerns a different device, and whoever holds the phone must see it.
- Both are in the inbox, so the phone shows them when the app next opens.
Chapter 7: Security on a Long-Lived Connection#
RFC 6455 "doesn't prescribe any particular way that servers can authenticate clients", and browsers' WebSocket and EventSource APIs can't set an Authorization header. Common patterns:
- A one-time connect ticket: an authenticated HTTPS call returns a ticket valid ~30 seconds for one use, sent in the connect URL. Never put a long-lived token in a URL.
- Token in the first frame: Discord's gateway has an app send its token in the
Identifypayload (Discord Developer Docs); Slack's client first fetches "the user token and websocket connection setup information" from its web backend (Slack Engineering, 2023). - Cookies, with an Origin check: browsers can send cookies on cross-site WebSocket handshakes (any cookie
SameSitedoesn't hold back), so checkOriginagainst an allow-list (RFC 6455 §10.2) or a malicious page can open a socket as the user.
Authenticate before touching the registry or inbox. Then handle the fact that tokens expire and sockets don't:
- Refresh in-band: before a 15-minute token expires, the client sends
reauth; no fresh token, and the gateway closes with an "authenticate again" code. - Revocation must reach the socket. "Log out all devices" publishes a revoke to the gateways holding the user (the registry says which) within seconds.
- Limit what a socket sends: Discord's gateway lets apps send 120 events per connection every 60 seconds and disconnects those that exceed it (Discord Developer Docs). Cap frame size and connections per user and IP.
Part IV: Real-World Application#
Chapter 8: How Slack, Discord and WhatsApp Do It#
8.1 Slack#
Slack's Channel Servers are mapped to channels by consistent hashing ("about 16 million channels" per host at peak), and its Gateway Servers hold "users' information and websocket channel subscriptions", "deployed across multiple geographical regions" with "a draining mechanism for region failures". A message goes in over HTTP, through an Admin Server to its Channel Server, which sends it "to every GS across the world that is subscribed to that channel". Slack reports "tens of millions of connected clients" and delivery "across the world in 500ms" (Slack Engineering, 2023).
The lesson that carries over: writes come in over HTTP and go out over the socket, so a dropped socket loses nothing.
8.2 Discord#
Each connected device gets an Elixir session process, and "an Erlang VM responsible for sessions can have up to 500,000 live sessions on it" (Discord Engineering, 2017). The session outlives the socket, so a client resumes at a resume_gateway_url with its session id and last sequence number (Discord Developer Docs). Per-connection streaming zstandard cut a typical MESSAGE_CREATE from 270 to 166 bytes in 2024, and with delta updates for passive sessions, gateway bandwidth fell "by almost 40%" (Discord Engineering, 2024). The Discord side quest covers guilds and fan-out.
8.3 WhatsApp#
WhatsApp's 2012 server held its 2.28 million connections "with plenty of CPU and memory to spare" (WhatsApp, 2012). Holding connections is cheap; what's expensive is everything that happens when they move.
Chapter 9: Common Mistakes#
- Using the socket as the record. Any gateway restart then loses messages. Inbox first; the socket is a hint.
- Unbounded per-connection buffers. A few slow phones become an OOM that drops everyone on the node.
- Fixed-delay reconnects.
sleep(2)re-creates the wave every 2 seconds. Use full jitter. - Trusting TCP to detect death. Two-hour keepalive against a 255-second carrier NAT.
- Deploying the gateway like a stateless service. Every restart forces up to 200,000 reconnects. Drain slowly.
Part V: Interview Preparation#
Chapter 10: Interview Tips#
10.1 When to Bring It Up#
When the prompt says in-app, live, instant, notification bell, feed updates, chat or dashboard, or targets sub-second latency for online users. Skip WebSockets when updates are rare (poll), clients are mostly offline (push), or one-way HTTP suffices (SSE).
10.2 Key Phrases#
TRANSPORT
"The bell is server-to-client, so SSE over HTTP/2 on the web: free reconnect,
Last-Event-IDresume, no 6-connection cap. Mobile gets a WebSocket because it acks and wants a compact protocol."
SIZING
"I size the gateway by connections and reconnect rate, not message rate: 200K per node for blast radius, and a zone loss re-admitted in about a minute with jitter."
RELIABILITY
"The socket isn't the record. Every notification is in the inbox with a per-user sequence number, clients resume after their last ack, and no ack in a few seconds falls back to push."
10.3 Common Follow-Up Questions#
| Question | Good answer |
|---|---|
| "A gateway dies. What do its users see?" | "A dropped socket, a jittered reconnect elsewhere, and a resume from the inbox. The registry ignores the gateway once its lease expires." |
| "How do you deploy it?" | "Slow randomised drains, one node per zone, a grace period longer than the drain." |
| "A client can't keep up?" | "Bounded queue: coalesce, shed, then close with a resumable code." |
| "Why not push for everything?" | "Push has no ordering and little storage (one per app on APNs, 100 on FCM), and the provider controls timing." |
| "How do you know a connection is dead?" | "Jittered client heartbeats every 25–30 s; the server closes after ~2.5 misses, the client after one missed pong; ack timeouts catch the rest." |
Chapter 11: Practice Problems#
Practice · problem 1
Problem 1: Live Order Tracking#
Setup: 3M concurrent customers watch an order's status and the rider's location, updated every 4 seconds. Which transport? What do you coalesce for a slow client? What happens when a phone switches from Wi-Fi to 4G?
Click for solution outline
- SSE (or the app's existing WebSocket): traffic is almost all server-to-client.
- Coalesce location to the latest point per order; never drop status changes, which carry sequence numbers.
- The network change kills TCP. Reconnect on the OS event, resume status after the last seq, take the next location point.
Practice · problem 2
Problem 2: The Monday Morning Reconnect#
Setup: A B2B chat product's 8M clients were disconnected over a long maintenance weekend. The window ends at 9:00 Monday. Estimate the load if all return within 30 seconds, and say how you'd stop the boot payload being the bottleneck.
Click for solution outline
- 8M ÷ 30 s ≈ 270,000 connects a second, ~270 nodes' worth at 1,000 admissions each. Over 5 minutes: ~27,000 a second.
- Lazy boot from an edge cache (Slack's Flannel idea), and resume for clients with local state.
- Ship a random reconnect window (0–300 s) in advance, and raise a boot throttle in steps, as Slack did in 2022.
Practice · problem 3
Problem 3: Logout Everywhere in 5 Seconds#
Setup: A user's phone is stolen. Every live session must be cut within 5 seconds, and no reconnect with old credentials may succeed.
Click for solution outline
- Bump a per-user token version in auth first, so tickets and
reauthwith old credentials fail even if they race the revoke. - Registry lookup, then publish
revoketo each gateway holding the user; they close those sockets with "authenticate again". - A push already on the lock screen can't be recalled, which is why push payloads shouldn't carry sensitive details.
Summary#
TRANSPORTS
- SSE for one-way feeds, especially over HTTP/2. WebSockets for two-way or binary. Long polling as the fallback
THE GATEWAY TIER
- A separate, rarely deployed tier, sized by connections, blast radius and reconnect admission, not message rate
ROUTING AND RELIABILITY
- Registry of user and device → gateway and epoch, with a lease per gateway
- One pub/sub channel per gateway; the inbox is the record, sequence numbers make resume a range query
FAILURE
- Jittered heartbeats under the shortest idle timeout; full-jitter reconnects; bounded queues; slow drains
Further Reading#
Engineering Posts#
- Blog
Slack — Real-time Messaging (11 April 2023): https://slack.engineering/real-time-messaging/
- Blog
Slack — Migrating Millions of Concurrent Websockets to Envoy (15 March 2021): https://slack.engineering/migrating-millions-of-concurrent-websockets-to-envoy/
- Blog
Slack — Flannel: An Application-Level Edge Cache (31 May 2017): https://slack.engineering/flannel-an-application-level-edge-cache-to-make-slack-scale/
- Blog
Slack — Slack's Outage on January 4th 2021 (1 February 2021): https://slack.engineering/slacks-outage-on-january-4th-2021/
- Blog
Slack — Slack's Incident on 2-22-22 (26 April 2022): https://slack.engineering/slacks-incident-on-2-22-22/
- Blog
Discord — You've Got (Too Much) Mail: Behind the Scenes of the 3/25/26 Voice Outage (29 April 2026): https://discord.com/blog/behind-the-scenes-of-the-3-25-26-voice-outage
- Book
Discord — How Discord Reduced Websocket Traffic by 40% (20 September 2024): https://discord.com/blog/how-discord-reduced-websocket-traffic-by-40-percent
- Blog
Discord — How Discord Scaled Elixir to 5,000,000 Concurrent Users (6 July 2017): https://discord.com/blog/how-discord-scaled-elixir-to-5-000-000-concurrent-users
- Blog
WhatsApp — 1 million is so 2011 (6 January 2012): https://blog.whatsapp.com/1-million-is-so-2011
Documentation, Standards and Papers#
- Docs
Discord Developer Docs — Gateway: https://github.com/discord/discord-api-docs/blob/main/developers/events/gateway.mdx
- Docs
RFC 6455 (WebSocket), RFC 8441 and RFC 9220 (WebSockets over HTTP/2 and HTTP/3): https://www.rfc-editor.org/rfc/rfc6455 · https://www.rfc-editor.org/rfc/rfc8441 · https://www.rfc-editor.org/rfc/rfc9220
- Docs
WHATWG HTML — Server-sent events: https://html.spec.whatwg.org/multipage/server-sent-events.html
- Docs
MDN — Using server-sent events: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- Docs
RFC 5382 (NAT and TCP), RFC 9000 (QUIC), RFC 9113 (HTTP/2): https://www.rfc-editor.org/rfc/rfc5382 · https://www.rfc-editor.org/rfc/rfc9000 · https://www.rfc-editor.org/rfc/rfc9113
- Docs
Linux epoll(7), ip-sysctl and fs sysctl: https://man7.org/linux/man-pages/man7/epoll.7.html · https://www.kernel.org/doc/Documentation/networking/ip-sysctl.rst · https://www.kernel.org/doc/Documentation/admin-guide/sysctl/fs.rst
- Docs
Redis — Pub/Sub: https://redis.io/docs/latest/develop/pubsub/
- Docs
AWS — Application Load Balancer attributes: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html
- Docs
Apple — Sending notification requests to APNs: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns
- Docs
Firebase — Collapsible messages and message lifespan: https://firebase.google.com/docs/cloud-messaging/customize-messages/collapsible-message-types · https://firebase.google.com/docs/cloud-messaging/customize-messages/setting-message-lifespan
- Paper
Wang et al. — An Untold Story of Middleboxes in Cellular Networks (SIGCOMM 2011): https://web.eecs.umich.edu/~zmao/Papers/netpiculet.pdf
this lesson in shorts · 4
play them all →One idea from this lesson in about a minute each, made for your phone.
Go deeper · reading trail
all reading →Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.
Pushy to the Limit: Evolving Netflix's WebSocket proxy for the future
Karthik Yagna, Baskar Odayarkoil, Alex Ellis · 2024- What
- How Netflix scaled Pushy, the WebSocket proxy that keeps connections open to its devices.
- Key idea
- Pushy holds hundreds of millions of concurrent connections at 99.999% delivery, tracks which server holds each device in a Push Registry, and scales on connections per instance.
- Why here
- This lesson's gateway tier and connection registry at Netflix's scale, sized by connections rather than by messages.
netflixtechblog.com answers automated fetchers with a Cloudflare 403; verified through an Internet Archive snapshot of the post (March 2026).
You've Got (Too Much) Mail: Behind the Scenes of the 3/25/26 Voice Outage
Bo Ingram, Stephen Birarda · 2026- What
- Discord's postmortem of the March 2026 outage that began with a routine change to its session service.
- Key idea
- Pods were terminated before handing off, 17% of sessions dropped, a start-session limit not re-tuned after a migration let too many reconnects through, and gateways ran out of memory.
- Why here
- The Preface of this lesson: a deploy becomes a reconnect storm, which is why drains must outlast grace periods and connects need admission control.
Migrating Millions of Concurrent Websockets to Envoy
Ariane van der Steldt, Radha Kumari · 2021- What
- How Slack moved its websocket edge tier, millions of concurrent connections, from HAProxy to Envoy.
- Key idea
- HAProxy reloads left old processes running for hours so long-lived websockets could drain; Envoy hot-restarts without dropping connections, and traffic moved over in DNS-weighted steps.
- Why here
- Why this lesson changes the proxy tier rarely and drains slowly: every restart of a connection holder is a wave of reconnects.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.



