~learn.
?

Day 0329 minPreface+60 XP

Real-Time Channels: WebSockets and SSE at Scale.

5 stages5 code

watch it first · newThe whole lesson in a 24-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 2 min

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:

Discord, 25 March 2026 (from Discord's postmortem)
  • 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.

ExplainerThe whole lesson: WebSockets and SSE at scale

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 · Stage 1 of 5 · 6 min

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:

text5 lines
id: 1184
event: notification
data: {"title": "₹2,400 received from Arjun", "unread": 4}

: keep-alive comment, ignored by the client

What 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.

Your turn · checkpoint 1not answered

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.

Think it through, or jot it down. Then compare.

One strong answer

  • Web bell: SSE over HTTP/2. One-way traffic, free reconnect and Last-Event-ID resume, 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_NOFILE is 1,024; raise it above your target. fs.nr_open defaults 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#

Your turn · checkpoint 2not answered

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?

Think it through, or jot it down. Then compare.

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.
WorksheetGateway fleet for the in-app channel (inputs are assumptions unless marked)
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 · Stage 2 of 5 · 7 min

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.
Routing: computed placement vs a registry

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#

text1 line
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:

  1. 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.
  2. 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-17 restarts 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.

DiagramThe in-app real-time path: from the router to a socket
%% 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 external

3.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.

DiagramConnect, authenticate, resume, deliver
%% 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 1184

3.5 The Gateway Connection, in Code#

pythonConnection · 55 lines
# 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)
                return

The important lines refuse work: a full queue or silence closes the socket, and the inbox keeps whatever was missed.

Your turn · checkpoint 3not answered

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?

Think it through, or jot it down. Then compare.

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 * jitter before 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).

Your turn · checkpoint 4not answered

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?

Think it through, or jot it down. Then compare.

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 · Stage 3 of 5 · 7 min

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#

  1. Full-jitter exponential backoff: a random wait between 0 and min(cap, base × 2^attempt). A fixed delay just moves the wave.
  2. Jitter the first attempt too, over a window the server can widen during incidents.
  3. 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).
  4. 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).

pythonstay_connected · 26 lines
# 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 READY payload 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:

  1. Bound every queue, in frames and bytes.
  2. Coalesce the replaceable: five "unread count" updates are worth the newest one.
  3. Shed the low-value first.
  4. 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.

DiagramDraining one gateway without a stampede
%% 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).
DiagramA client connection's life
%% 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
Your turn · checkpoint 5not answered

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.

Think it through, or jot it down. Then compare.

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:

  1. No live connection? Push at once, if preferences allow.
  2. No ack within a few seconds? Treat the socket as a zombie and push.
  3. Acked on one device? Skip push for routine events; security alerts go everywhere.
  4. Collapse replaceable events (apns-collapse-id, FCM collapse keys).
pythondeliver · 11 lines
# 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"
Your turn · checkpoint 6not answered

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?

Think it through, or jot it down. Then compare.

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 Identify payload (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 SameSite doesn't hold back), so check Origin against 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 · Stage 4 of 5 · 2 min

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#

  1. Using the socket as the record. Any gateway restart then loses messages. Inbox first; the socket is a hint.
  2. Unbounded per-connection buffers. A few slow phones become an OOM that drops everyone on the node.
  3. Fixed-delay reconnects. sleep(2) re-creates the wave every 2 seconds. Use full jitter.
  4. Trusting TCP to detect death. Two-hour keepalive against a 255-second carrier NAT.
  5. Deploying the gateway like a stateless service. Every restart forces up to 200,000 reconnects. Drain slowly.

Part V · Stage 5 of 5 · 5 min

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).

❯ Say this

10.2 Key Phrases#

Phrases that show you've run one of these
TRANSPORT

"The bell is server-to-client, so SSE over HTTP/2 on the web: free reconnect, Last-Event-ID resume, 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 reauth with old credentials fail even if they race the revoke.
  • Registry lookup, then publish revoke to 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#

DAY 3 KEY TAKEAWAYS
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#

Documentation, Standards and Papers#

this lesson in shorts · 4

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 03 · Real-Time Channels: WebSockets and SSE at Scale
Finish Day 3Stages clear as you read them (0/5 so far). Skipping ahead doesn't count: marking complete unlocks after ~6 more min of reading.

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.

NetflixBlog post · ~16 min

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.
taught here: The gateway tier

netflixtechblog.com answers automated fetchers with a Cloudflare 403; verified through an Internet Archive snapshot of the post (March 2026).

DiscordPostmortem · ~12 min

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.
taught here: Reconnect storms
SlackBlog post · ~14 min

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.
taught here: Graceful drain on deploy

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…