System Design

Realtime Updates

A comment posted once during a livestream’s biggest moment has to show up, within about a second, in the browser of every one of a million people watching. Nobody asked your server for that comment individually — it has to be pushed. This lesson builds the delivery path — polling, Server-Sent Events, WebSockets, a pub/sub decoupling layer, a stateful connection tier sized by its real bottleneck, and the reconnection-storm math that keeps it standing — from a single write fanning out to a million reads.

Prerequisites: a server can respond to an HTTP request + a TCP connection stays open until something closes it. Everything else is built here.
9
Chapters
9
Simulations
0
Assumed Knowledge

Chapter 0: The Fan-Out Problem

It is the tenth round and a knockdown just landed. The comment box under the livestream player is scrolling too fast to read — thousands of people typing in the same three seconds. You are not worried about the writes; one more row in a comments table is nothing. You are worried about the other side of it: the comment that user A just posted needs to appear, within about a second, in the browser of every one of the 1,000,000 other people currently watching this exact match. Nobody on the receiving end asked your server for that comment. It has to be pushed to them, all at once, without them requesting it.

That single sentence — one write, delivered to a million readers who never asked — is the whole problem this lesson exists to solve, and it breaks an assumption baked into almost everything else you have built. A normal API request has a fan-out of one: one requester asks, one response goes back to that same requester. A live comment feed has a fan-out in the hundreds of thousands or millions, and that multiplier is large enough to change which parts of the ordinary request/response model even still apply.

Naming the multiplier, with real numbers

Start with what is actually happening on this stream right now. 1,000,000 viewers are connected. During a hype moment like this one, comments arrive at roughly 40 a second — call it HYPE_RATE. Every single one of those 40 comments has to reach all 1,000,000 viewers, not just the one who posted it. Multiply:

40 comments/sec × 1,000,000 viewers = 40,000,000 deliveries per second

Forty million deliveries a second, generated by a write rate of only 40 a second. That ratio — 1,000,000 deliveries for every 1 write — is the fan-out factor, and it is the reason this system cannot be built the way a typical CRUD API is built. A comfortably busy ordinary API, serving every endpoint across an entire product, might do 5,000 requests a second in total. This one comment stream, on its own, is already producing eight thousand times that much delivery volume, and it did it from a write rate that would not even register as a rounding error on that same API’s dashboard.

What is actually moving over the wire

A delivery is not free just because it is small. Each comment, once packaged for a viewer’s browser, carries a username, the comment text, a timestamp, and a small amount of framing — call it MSG_BYTES = 150 bytes per delivered message, a realistic figure for a compact JSON payload. Multiply deliveries by size to get the number that actually determines how many machines this needs:

40,000,000 deliveries/sec × 150 bytes = 6,000,000,000 bytes/sec = 6 GB/s

Convert to the unit a network engineer actually budgets in — bits per second, since that is how network capacity is rated:

6,000,000,000 bytes/sec × 8 = 48,000,000,000 bits/sec = 48 Gbps

Forty-eight gigabits a second, sustained, for as long as the hype moment lasts. That number alone tells you this cannot live on one machine, no matter how it is built — not because of clever code, but because of physics: a single network interface has a rated ceiling, and 48 Gbps blows past what any one commodity box can push.

Fan-out at hype rate — one write, a million deliveries

Drag the comment rate. Watch delivery volume and egress bandwidth scale with it, holding the viewer count fixed at 1,000,000. Dots animate outward from the single publisher to represent (a small sample of) the fan-out.

comments / sec40

How much of a box's network capacity does this actually cost

A modern server often ships with a 10 or 25 Gbps network interface, and it is tempting to divide 48 Gbps by 25 and call it 2 boxes. That number is a lie for messages this small. A 150-byte payload pays a fixed tax in kernel overhead, TCP/TLS framing, and per-packet syscall cost that has almost nothing to do with the raw wire speed — a NIC rated for 25 Gbps of large, bulk transfers rarely sustains anywhere near that when the traffic is millions of tiny, independent writes to separate sockets. A realistic sustained figure for this kind of small-message, many-socket egress, once that overhead is accounted for, is closer to 2 Gbps per box. Chapter 6 makes the reason for that number precise; for now, treat it as the honest working figure, and notice already that it is nowhere near the 25 the spec sheet advertises.

48 Gbps ÷ 2 Gbps/box = 24 boxes, by bandwidth alone

Twenty-four boxes just to physically move the bytes, before a single line of application logic runs on any of them. Keep that number in your pocket — Chapter 6 revisits it against a second, tighter constraint, and the two numbers do not agree.

Stress-testing the rate assumption

Forty comments a second was read off tonight’s specific stream. Before trusting it enough to design around it, see how the whole calculation moves if that number is wrong, holding viewers fixed at 1,000,000 and the 2 Gbps/box budget fixed:

Comment rateDeliveries/secEgress bandwidthBoxes (bandwidth floor)
10/sec — a quiet mid-match lull10,000,00012 Gbps6
20/sec20,000,00024 Gbps12
40/sec — tonight’s knockdown40,000,00048 Gbps24
80/sec — a controversial call, chat exploding80,000,00096 Gbps48
160/sec — the final bell, everyone typing at once160,000,000192 Gbps96

Every doubling of the comment rate doubles every column beside it — deliveries, bandwidth, and box count all move together, because they are all just the same product with one factor changed. Design for the quiet middle of the table and the final-bell spike will melt the fleet; design for the final bell and the quiet lull runs comfortably under capacity the rest of the night. This lesson designs for the spike.

Key insight. Everything in the rest of this lesson attacks this one multiplication — rate × viewers × bytes — from a different angle. Polling tries to pay for it with request volume and fails (Chapter 1). A push protocol pays for it with held-open connections instead (Chapters 2–3). A pub/sub layer keeps the servers that receive a comment separate from the servers that hold sockets to viewers, so the multiplication happens locally, in-process, instead of over the network for every single viewer (Chapter 5). None of that makes the 40,000,000 deliveries/sec disappear — it only decides which machines pay for which slice of it.

Concept → realization: what one delivered message actually looks like

The 150-byte estimate above is not a guess pulled from nowhere — here is the actual JSON payload one viewer’s browser receives for one comment, with its byte cost broken down field by field:

json — one delivered comment, ~150 bytes on the wire
{                                    
  "type": "comment",               // 18 bytes — lets the client dispatch on message kind
  "id": "c_9f2a1b",               // 20 bytes — the resume token Chapter 7 replays from
  "u": "ringside_fan_88",          // 24 bytes — username, kept short deliberately
  "t": "HE’S DOWN!!",             // 20 bytes — the comment text itself
  "ts": 1755500823411         // 16 bytes — epoch millis for client-side ordering
}                                     // + ~50 bytes of braces, quotes, and WS/SSE framing

Add the pieces: roughly 100 bytes of actual field data plus about 50 bytes of JSON punctuation and transport framing lands right at the 150-byte figure used above. Notice what is not in that payload — no avatar image, no full user profile, no HTML markup. Every byte trimmed here is a byte the 40,000,000-times-a-second multiplication does not have to pay for, which is why real systems fight hard over payload shape in exactly this kind of high-fan-out path, in a way they never would for a normal one-response-per-request API call.

Peak viewership is itself an estimate — stress it too

The 1,000,000-viewer figure came from tonight’s dashboard, not from a contract. Before sizing an entire fleet against it, check how far off a reasonable estimation error could be. Say the platform has 40,000,000 registered users and, for a marquee fight like this one, roughly 2.5% of them tune in concurrently at the peak:

40,000,000 × 0.025 = 1,000,000 concurrent viewers — tonight’s figure

That 2.5% concurrency fraction is itself a rough historical average, not a physical law. Vary it and watch what happens to every number derived so far, holding the 40/sec comment rate fixed:

Concurrency fractionViewersDeliveries/secEgress bandwidth
1.5% (a modest fight)600,00024,000,00028.8 Gbps
2.5% (tonight, measured)1,000,00040,000,00048 Gbps
4.0% (a title unification bout)1,600,00064,000,00076.8 Gbps
7.5% (a once-a-decade global event)3,000,000120,000,000144 Gbps

A fleet sized only for tonight has zero margin for the bout the promoter is already selling tickets to. This is not a hypothetical caution — it is the same lesson Chapter 0's twin, Scaling Reads, teaches about traffic multipliers: capacity planning against last night's peak is capacity planning for last night.

Why the write-side server can't just hold the sockets itself

It is worth asking, before building any of the machinery in Chapters 5 and 6, why the ordinary API server that received the comment write can't simply keep 1,000,000 open sockets itself and push straight out to them. Run the numbers on that shortcut. A typical application server process handles a few thousand concurrent requests comfortably; pushing that same process to hold 1,000,000 long-lived sockets means every other request it serves — logins, other API calls, completely unrelated features — now competes for memory and CPU with comment fan-out on the exact same box. And it does not scale: the moment traffic outgrows one box, the write path (which needs to reach every viewer, wherever their socket happens to live) and the read path (which needs horizontal replicas for ordinary reasons) are now tangled into the same fleet, for no reason except that nobody separated them. Chapter 5 introduces the fix — a dedicated connection tier, decoupled from the write path by a message broker — and Chapter 6 sizes it properly.

The roadmap this problem implies

Every remaining chapter attacks the same 40,000,000-deliveries-a-second reality from a different angle, and it is worth previewing the order, since each layer assumes the one before it:

ChapterAttacks fan-out by
1 · Pollingtrying to answer with a request/response tax — and showing exactly why that tax is unaffordable
2 · Server-Sent Eventsa real one-way push channel, held open instead of re-requested
3 · WebSocketsfull-duplex push, at the cost of sticky, stateful infrastructure
4 · Choosing a Protocolputting the numbers from Chapters 1–3 side by side instead of guessing
5 · Pub/Sub Decouplingseparating the server that receives a comment from the servers that hold sockets to a million viewers
6 · The Stateful Connection Tierfinding the real per-box ceiling — and it is not the 24-box bandwidth floor computed above
7 · Reconnection Stormssurviving the moment a slice of that fleet dies at once
8 · Assembling the Realtime Paththe whole system, live, for any viewer count

Why not just provision more bandwidth and be done with it

Twenty-four boxes for 48 Gbps sounds like an easy purchase order, so it is worth asking honestly why that alone does not close the ticket. Three reasons, each one concrete:

ReasonConcretely
Bandwidth is not the tightest constraintChapter 6 derives a second ceiling — how much CPU it costs to actually write 40 messages/sec out to every locally-held socket — and that number needs more boxes than 24, not fewer. Bandwidth capacity bought in isolation goes unused while CPU maxes out first.
One box holding sockets is a single point of failure for every viewer connected to itUnlike a stateless API box that can vanish and simply have its next request routed elsewhere, a box holding 40,000 open WebSocket or SSE connections takes all 40,000 of those viewers offline the instant it dies — Chapter 7 is entirely about surviving that moment.
The target movesTonight’s stream tops out at 1,000,000 viewers. A bigger event — a title fight, a World Cup final — can be five to ten times that. A fleet sized for exactly tonight’s peak has no margin for the next one.

None of that means 24 boxes is a wrong number — it is a real, necessary floor. It means bandwidth alone is not sufficient to answer “how many machines,” and every later chapter narrows in on the other constraints that actually decide the fleet size.

The delivery path, end to end, before any protocol has been chosen

Whatever protocol ends up carrying an individual comment to an individual browser, the path a comment takes from the moment it is posted is the same shape every time. Naming the four hops now gives every later chapter a fixed map to build onto:

1 · write
a viewer POSTs a comment to an ordinary stateless API server · this part is a completely normal database write
2 · publish
that API server has no socket to any viewer — it hands the comment to a message broker instead (Chapter 5)
3 · local fan-out
a fleet of connection-holding servers, each subscribed to this stream's topic, receives the comment once and loops over its own locally-held sockets (Chapters 5–6)
4 · deliver
each held socket — a long poll, an SSE stream, or a WebSocket — pushes the 150-byte payload down to one browser

Chapters 1 through 4 are entirely about step 4: which kind of held (or re-requested) connection carries that last hop. Chapters 5 through 7 are about steps 2 and 3: how a comment gets from the one API server that received it to the tens of boxes that need to broadcast it, and what happens when one of those boxes disappears mid-broadcast.

The four signals that catch a fan-out overload before viewers notice

Every number derived above is also worth graphing continuously, because the failure mode here does not look like a crash — it looks like comments arriving late, then very late, then not at all, while every health check still reports green:

MetricWhat it isWhy it matters here
Publish-to-deliver latency, p50 and p99time from a comment hitting the broker to it leaving a connection server's socketthis is the number viewers actually feel; it lags every other metric, so watch it directly rather than inferring it
Per-box egress bandwidthbytes/sec actually leaving each connection server's NICapproaching the realistic 2 Gbps/box ceiling from this chapter, well before any 10 or 25 Gbps spec-sheet number
Broker consumer laghow far behind a connection server's subscription is from the broker's latest published messagea growing lag on even one connection server means its viewers are silently falling behind everyone else's
Held-connection count per boxlive socket count on each connection serverdirectly determines how many viewers go dark if that one box fails — the number Chapter 7's storm math depends on

Push versus pull: the vocabulary the rest of this lesson uses

Two words carry a lot of weight from here forward, so pin them down precisely. In a pull model, the client initiates every exchange — it asks, the server answers, and the server never speaks unless spoken to. Ordinary HTTP APIs are pull by default. In a push model, the server initiates delivery whenever it has something new, over a channel the client opened once and left standing. Polling (Chapter 1) is pull dressed up to imitate push, by asking so often it feels immediate. SSE and WebSockets (Chapters 2 and 3) are genuinely push. The distinction is not academic: it is the difference between paying for delivery with request volume and paying for it with held-open connections, and every chapter from here on is really just working out the exchange rate between those two currencies for a specific slice of the problem.

The number to hold onto leaving this chapter. 40,000,000 deliveries a second, at tonight’s peak, from a write rate of 40. Every protocol this lesson considers is graded against the same question: given that multiplier is fixed by the business (a livestream with a million concurrent viewers), where does the resulting cost land — on request volume, on held-open connection count, or on server-side CPU — and which of those three is actually cheapest to buy more of?

One more honest number: what happens if nothing is built at all

It is worth sitting with the do-nothing baseline for a moment, because it clarifies why any of this machinery is justified. If the comment feature simply refreshes the whole page every 2 seconds — the crudest possible implementation, no dedicated endpoint, just a full page reload — each reload re-fetches the entire page: HTML, CSS, images, the works, easily 200–500KB per load instead of 800 bytes. At 500,000 reloads/sec and a conservative 200KB each:

500,000 × 200,000 bytes = 100,000,000,000 bytes/sec = 800 Tbps

Eight hundred terabits a second — over sixteen times the 48 Gbps hype-moment content delivery figure this entire lesson is built around, just from re-fetching a whole page instead of a small JSON payload. That comparison is the real argument for building any of this deliberately: the gap between the naive baseline and even the crudest disciplined approach (Chapter 1's dedicated, minimal-payload polling endpoint) is already two to three orders of magnitude, before push protocols enter the picture at all.

The stress table shows comment rate doubling from 40/sec to 80/sec doubles egress bandwidth from 48 Gbps to 96 Gbps. If instead the comment rate stayed at 40/sec but the viewer count doubled from 1,000,000 to 2,000,000, what happens to egress bandwidth?

Chapter 1: Polling

The obvious first move, and the one every web developer reaches for before learning anything else, is to keep asking. The client’s browser sends GET /comments?since=… every couple of seconds, forever, and the server answers with whatever is new since the last check — or, most of the time, with nothing at all. This is short polling, and it is worth taking seriously enough to compute exactly what it costs, because the arithmetic is the fastest way to see why every realtime system eventually abandons it.

The fixed tax nobody notices until it is huge

Set the poll interval at 2 seconds — a reasonable-feeling compromise between freshness and load. With 1,000,000 viewers all polling on that same interval:

1,000,000 clients ÷ 2 sec = 500,000 requests per second

Half a million queries a second, arriving continuously, whether or not anyone has posted a single comment. That is the defining trait of polling: its cost is a function of client count and interval, completely independent of how much is actually happening. Chapter 0’s fan-out math scaled with activity; this number does not care about activity at all.

How many of those 500,000 requests are wasted

Now bring activity back in, but pick it honestly — not the 40/sec knockdown spike from Chapter 0, but the quiet stretch between big moments, where comments trickle in around 0.1 a second, one roughly every ten seconds. Treat comment arrivals as a Poisson process with that rate and ask: what fraction of any given 2-second poll window contains zero new comments?

P(0 comments in 2s) = e-λ t = e-0.1 × 2 = e-0.20.819

About 82% of every poll, during the quiet stretches, comes back completely empty — a full HTTP round trip that accomplishes nothing except confirming, again, that nothing happened. Multiply that fraction back into the 500,000 QPS:

500,000 × 0.819 ≈ 410,000 empty requests per second

Four hundred ten thousand requests a second, every second, that exist purely to say “still nothing.” That is not a rounding error in a system’s load profile — it is most of the load.

Costing the waste in bytes

Even an empty response is not free. A typical short-poll round trip — request headers, cookies, an auth token, a small JSON response envelope, response headers — costs roughly 800 bytes of pure transport overhead, delivering zero new content. Apply that to the empty-request rate:

410,000 × 800 bytes = 328,000,000 bytes/sec ≈ 2.6 Gbps

Two point six gigabits a second of bandwidth, spent entirely on confirming absence. Compare that against Chapter 0’s 48 Gbps hype-moment egress: the polling tax alone, during a quiet stretch, already costs more than five percent of what the busiest possible real content delivery costs — and it buys nothing.

QuantityValue
Poll interval2 sec
Total QPS (activity-independent)500,000
Quiet-stretch comment rate0.1/sec
Fraction of polls empty~82%
Empty QPS~410,000
Bandwidth spent confirming nothing~2.6 Gbps
Short polling — fixed request tax vs. actual comment rate

Drag the poll interval and the underlying comment rate. Watch total QPS (a function of the interval alone) and the empty-response fraction (a function of both) move independently.

poll interval (sec)2.0
comment rate (per sec)0.10

Latency: the other cost of a fixed interval

Even the 18% of polls that do find something pay a latency tax. A comment that lands one millisecond after a client's last poll will not be seen until that client's next poll fires — up to a full 2 seconds later. Averaged over when in the interval a comment happens to arrive, the expected delay is half the interval:

avg delay = (interval) ÷ (2) = (2s) ÷ (2) = 1 second

A full second of average lag on top of a mostly-wasted request stream. Shrinking the interval to improve that latency makes the waste problem strictly worse — halve it to 1 second and QPS doubles to 1,000,000 while the empty fraction barely moves. Short polling has exactly one dial, and turning it in either direction only trades one cost for the other.

Long polling: holding the request open instead of repeating it

Long polling changes the contract: instead of responding immediately with “nothing new,” the server holds the request open, waiting, and only responds the moment new data actually exists — or after a timeout, whichever comes first. Set that timeout at 25 seconds and keep the quiet-stretch rate at 0.1/sec. The expected time until the next comment arrives (or the timeout fires, whichever is sooner) is:

E[min(X, T)] = (1 − e-λT) ÷ λ = (1 − e-0.1 × 25) ÷ 0.1 = (1 − e-2.5) ÷ 0.1 = (1 − 0.082) ÷ 0.1 ≈ 9.18 sec

Each long-poll cycle now lasts about 9.18 seconds on average, instead of a fixed 2. Recompute QPS with that as the effective interval:

1,000,000 ÷ 9.18 ≈ 108,900 requests/sec

QPS falls from 500,000 to roughly 108,900 — a 4.6× reduction — and the fraction of cycles that come back genuinely empty (hitting the 25-second timeout with nothing having happened) drops to just e−2.5 ≈ 8.2%, down from 82%. Long polling looks, on this arithmetic alone, like a clear win.

The catch: what “held open” actually costs

It is a clear win on request volume and a hidden cost on something else: concurrency. Apply Little’s Law — average number in the system equals arrival rate times average time in the system — to the long-poll cycles themselves:

L = λ × W = 108,900/sec × 9.18 sec ≈ 999,9001,000,000

That result is not a coincidence, and it is the whole point of this section: at any given instant, essentially all 1,000,000 clients have a request open and pending on some server, because each one immediately re-issues a new long poll the moment the previous one resolves. Long polling traded 500,000 stateless requests a second for roughly 1,000,000 simultaneously held-open connections, plus 108,900/sec of connection churn on top of that. It did not escape the held-connection cost that Chapters 2 and 3 are about to formalize — it backed into it by accident, while also still paying a meaningful request-setup tax.

What long polling actually buys. Not fewer held-open connections — it ends up holding nearly as many as a dedicated push channel. What it buys is lower latency than short polling without a tighter interval, using ordinary HTTP infrastructure with no protocol upgrade. That is a real advantage in restrictive network environments that cannot do WebSocket upgrades or persistent SSE streams — but it is not a cheaper architecture, and the next two chapters build the thing long polling was reaching for on purpose.

Concept → realization: the bytes in one wasted round trip

The 800-byte overhead estimate deserves the same scrutiny the 150-byte comment payload got in Chapter 0. Here is an actual short-poll request and its empty response, field by field:

http — one empty short-poll round trip, ~800 bytes total
GET /comments?since=1755500821000 HTTP/1.1
Host: live.example.com                      // ~25 bytes
Cookie: session=8f2a1c9e...                  // ~120 bytes — auth, every single poll
User-Agent: Mozilla/5.0 (...)                // ~150 bytes
Accept: application/json                     // ~25 bytes
// + TCP/TLS record framing on an already-open connection: ~80 bytes

HTTP/1.1 200 OK
Content-Type: application/json               // ~30 bytes
Content-Length: 2                          // response headers: ~150 bytes

[]                                           // 2 bytes of actual payload — nothing new

Two bytes of real information — an empty array — riding on top of roughly 800 bytes of headers that exist purely to authenticate and route the request. The signal-to-noise ratio on an empty poll is under one percent, and it happens 410,000 times a second during a quiet stretch.

The short-poll cycle, one client's view

1 · request
client sends GET with the timestamp of the last comment it saw
2 · server checks
query the comments store for anything newer — usually nothing, during a quiet stretch
3 · respond immediately
200 OK with an empty array, or the new comments if any exist · connection closes (or returns to a pool)
4 · wait, repeat
client sleeps for the poll interval and starts again at step 1, forever, for as long as the page is open

That fourth step is the one worth staring at: the interval is a client-side sleep, chosen once, with no way to react to what is actually happening on the stream. It cannot speed up during a knockdown and cannot slow down during a lull — the entire architecture is blind to activity by design.

Short versus long polling, side by side

PropertyShort polling (2s)Long polling (25s cap)
Requests/sec, quiet stretch500,000~108,900
Fraction genuinely empty~82%~8.2%
Avg concurrently held requests~0 (stateless round trips)~1,000,000
Avg latency to see a new comment~1 secnear-immediate once posted
Infra needed beyond plain HTTPnoneservers able to hold ~1M pending requests without one thread each

Read that last row carefully. Holding a million requests open without dedicating a million OS threads to them requires the same kind of event-loop, non-blocking I/O architecture that a real SSE or WebSocket server needs anyway. Long polling does not avoid building that machinery — it just builds a less efficient version of it, wrapped in a protocol designed for one-shot exchanges.

What breaks first if this ships as-is

Picture this shipping to production with short polling at a 2-second interval and no other changes. The 500,000 QPS does not go to comment logic — it goes to whatever stateless API tier fronts the app, which now has to authenticate, route, and answer half a million mostly-empty requests a second, competing for the exact same capacity as every other feature in the product. A feed load that used to take 12ms starts queueing behind polling traffic during every single hype moment, which is precisely the incident this lesson's companion Scaling Reads lesson diagnoses from the other side — a read-capacity wall, except this one is entirely self-inflicted by a design choice, not organic user growth.

A subtler cost: per-domain connection limits

Browsers cap how many simultaneous TCP connections a single tab will open to one hostname — historically six per origin under plain HTTP/1.1. That limit matters more for long polling than short polling: a page holding one long-lived request open to live.example.com for comments, plus separate connections for images, other API calls, and analytics beacons, can bump into that ceiling and start queueing its own unrelated requests behind the held-open comment poll — a self-inflicted head-of-line block, invisible in server-side metrics because the server did nothing wrong. HTTP/2 multiplexes many logical streams over one physical connection and mostly removes this specific limit, which is one more reason production systems building any of this care about which HTTP version actually reaches every client, not just what the server offers.

Sizing a real long-polling server

Take the 108,900 QPS and ~1,000,000 concurrently held requests derived above and ask what kind of server process can actually hold that many pending requests. A traditional thread-per-request model — one OS thread blocked per open request — costs roughly 1–8MB of stack space per thread depending on the runtime; even at the cheap end:

1,000,000 threads × 1MB = 1,000,000MB = 1TB of stack memory alone

A terabyte of memory just for idle thread stacks, before any application logic runs, is not a realistic number to provision. An event-loop architecture — one that represents a pending long-poll as a small callback or continuation object rather than a dedicated OS thread — costs closer to a few kilobytes per pending request, bringing the same 1,000,000-request figure down to a few gigabytes. Chapters 2, 3, and 6 build on exactly this event-loop assumption; it is not optional machinery, it is the only way any of these designs are affordable at all.

What monitoring a polling-based system actually watches

MetricWhy it matters for polling specifically
Empty-response ratethe direct measure of wasted round trips; a rate near the theoretical e−λt figure means the interval matches activity, a much higher rate means the interval is too aggressive for current activity
QPS versus registered active clientsQPS should track clients ÷ interval almost exactly for short polling; a mismatch usually means client-side retry logic is misbehaving under errors
Held-request count (long polling only)should track total connected clients closely, per the Little's Law result above; a gap usually means requests are timing out and reconnecting faster than expected
p99 time-to-see-a-new-commentthe actual user-facing latency; for short polling this should hover near half the interval, for long polling near the network round trip
The number to carry into Chapter 2. Long polling's real lesson is not “use long polling” — it is that once you accept holding ~1,000,000 connections open is unavoidable for real-time delivery at this scale, you should stop pretending the transport is request/response at all. The next two chapters build protocols honest about that fact from the start.

A worked comparison: cost per delivered comment

One more angle makes the tradeoff concrete: divide total bandwidth cost by the number of comments actually delivered, for both approaches, over one quiet 60-second stretch (0.1 comments/sec, so 6 comments total delivered to 1,000,000 viewers each — 6,000,000 real deliveries).

Total round tripsOverhead bytesOverhead per real delivery
Short polling (2s)30,000,000 (500,000/sec × 60s)24,000,000,000 (×800B)~4,000 bytes
Long polling (25s cap)~6,534,000 (108,900/sec × 60s)~5,227,200,000 (×800B)~871 bytes

Long polling costs roughly a fifth as much overhead per real comment delivered, purely from cutting round-trip count — and both numbers still dwarf the 150 bytes an actual comment payload costs to deliver once. That gap between “overhead per delivery” and “payload per delivery” is exactly what a genuine push channel closes to nearly zero, which is where this lesson turns next.

One caveat worth stating plainly before moving on: none of the numbers in this chapter assumed a single failure. Every one of those 500,000 (or 108,900) requests a second is also a request that can time out, retry, or arrive at an overloaded server and get a 503 — and naive client-side retry logic on a failed poll behaves exactly like the reconnection storms Chapter 7 studies in depth, just smaller in scale and easier to miss in a dashboard full of mostly-successful requests. Polling's failure modes are not a separate topic from push protocols' failure modes; they are the same failure mode, arriving earlier and quieter.

A moderate rate in between the extremes

The 0.1/sec quiet-stretch rate and Chapter 0’s 40/sec hype rate sit at opposite ends of a real broadcast’s arc. Pick a rate in between — 2 comments a second, the kind of steady chatter a popular stream keeps up between big moments — and re-run the same Poisson calculation, still at the 2-second poll interval.

P(0 comments in 2s) = e-λ t = e-2 × 2 = e-40.0183

Just 1.8% of polls come back empty at this rate — a completely different picture from the 82% empty fraction during a quiet stretch. Apply that fraction to the fixed 500,000 QPS:

500,000 × 0.0183 ≈ 9,150 empty requests per second

At moderate activity, short polling’s waste nearly disappears — not because polling improved, but because there is almost always something new to report, so a poll almost never wastes its round trip. The problem was never the empty responses specifically; it was the fixed 500,000 QPS tax that exists regardless of activity, and this scenario makes that plain by showing how little of the tax actually depends on the empty fraction.

Comment rateP(poll is empty)Empty QPS (of 500,000 total)
0.1/sec (quiet stretch)~82%~410,000
2/sec (moderate chatter)~1.8%~9,150
40/sec (hype moment)<0.01%~35

Read the bottom row carefully: during an actual hype moment, short polling’s 500,000 QPS is almost entirely useful traffic — the waste this chapter has spent so much time on is specifically a quiet-stretch problem. That does not rescue short polling, though; the fixed 500,000 QPS tax is paid at every rate, useful or not, and Chapter 0’s traffic model makes clear that a live broadcast spends most of its runtime in the quiet-stretch regime, not the hype-moment one.

Long polling with a 25-second timeout drops QPS from 500,000 to about 108,900 versus 2-second short polling. Given that reduction, why does the server still need roughly the same connection-holding capacity as a dedicated push protocol like SSE?

Chapter 2: Server-Sent Events

Long polling spent the last chapter accidentally reinventing a held-open push channel while still paying request-setup overhead for the privilege. Server-Sent Events, usually called SSE, is what you get when you stop accidenting into it and build the real thing: a single HTTP response that never finishes, streaming new events down to the browser for as long as the connection stays open. One request, opened once, delivering an unbounded number of comments over its lifetime.

The wire format, byte by byte

SSE is not a new protocol layered on top of HTTP — it is an ordinary HTTP response with one header that changes everything:

http — the SSE response, opened once
HTTP/1.1 200 OK
Content-Type: text/event-stream            // tells the browser: never close this
Cache-Control: no-cache
Connection: keep-alive

id: 8214                                    // this event's sequence number
event: comment                              // event type, dispatched client-side
data: {"u":"ringside_fan_88","t":"HE'S DOWN!!"}

id: 8215
event: comment
data: {"u":"cutman_joe","t":"stoppage incoming"}

// connection stays open — the next event could arrive in 4ms or 40 seconds

Every field matters. data: carries the payload — the same 150-byte comment object from Chapter 0, now delivered with almost none of the 800-byte round-trip tax, because there is no round trip; the connection is already open. id: is what makes reconnection safe, covered in full below. Blank lines separate events; the browser's built-in EventSource API parses this stream natively — no custom client library required, unlike WebSockets.

What one open SSE connection actually costs

The tradeoff long polling was reaching for shows up immediately: instead of ~1,000,000 requests churning through the server 108,900 times a second, there are simply 1,000,000 connections, held open, doing nothing until an event needs pushing down them. Cost that memory honestly. Each idle connection costs roughly 20 KB — TCP send/receive buffers, TLS session state, and a small application-level object tracking which stream this viewer is subscribed to. Budget 16 GB of a box's RAM for connection state (leaving the rest for the OS and application logic):

16,000,000 KB ÷ 20 KB = 800,000 connections per box, by memory alone

For 1,000,000 viewers, memory alone suggests as few as 2 boxes (1,000,000 ÷ 800,000, rounded up). Hold that number loosely — Chapter 6 adds a second constraint, the CPU cost of actually pushing messages out through all those held sockets during a hype moment, and it lands on a very different, much larger fleet size. Memory tells you how many sockets fit in RAM. It does not tell you how fast you can write to all of them at once.

SSE connection memory vs. hype-moment CPU (preview of Chapter 6)

Drag per-connection memory overhead. Watch the memory-only box estimate move — and notice the CPU-bound estimate (computed properly in Chapter 6) barely reacts to this slider at all, because it depends on a completely different resource.

KB per connection20

Reconnection: the browser already knows how to do this

Networks blip. Mobile devices switch from WiFi to cellular. A load balancer restarts. Any of these drops an SSE connection, and the browser's native EventSource handles it without a single line of application code: on disconnect, it automatically retries, by default after about 3 seconds, and — this is the part that matters — it sends the id of the last event it successfully received back to the server, in a Last-Event-ID header.

http — the automatic reconnect request
GET /stream/comments HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 8215                    // "I last saw event 8215 — send me what came after"

The server keeps a small ring buffer of its most recent events per stream, and on seeing a Last-Event-ID, replays everything after that id before resuming the live stream. This is what turns a network blip from “silently missed comments” into “a brief pause, then perfectly caught up” — and it costs nothing extra to implement, since the id was already being sent on every event.

Sizing the replay buffer

How many events does that ring buffer need to hold? Set it by the worst reconnection delay you are willing to tolerate losslessly. A dropped connection typically reconnects within 1–3 seconds — DNS is cached, TLS session resumption avoids a full handshake — but give it real margin: size the buffer for up to 12.5 seconds of missed events at the Chapter 0 hype rate of 40/sec:

40 events/sec × 12.5 sec = 500 events buffered per stream

Five hundred events, each ~150 bytes: a 75 KB ring buffer per active stream — trivial to hold in memory, and it single-handedly turns SSE's reconnect story from “hope you didn't miss much” into a guarantee, up to that 12.5-second window. Chapter 7 returns to this exact buffer when reconnection delays get much worse than a blip — a real storm, not a network hiccup — and shows the buffer alone stops being enough.

What SSE cannot do

SSE is deliberately one-way. The stream carries server-to-client events; it carries nothing back. Posting a new comment is a completely separate, ordinary HTTP POST request, unrelated to the open stream. For a pure broadcast — exactly this lesson's live-comments case, where the vast majority of connected clients only ever receive — that limitation costs nothing, because there was never any client-to-server data to send over this particular channel anyway. Two more practical limits round out the picture: text only (no binary frames, meaning binary payloads must be base64-encoded, inflating size by about a third), and, over plain HTTP/1.1, browsers cap concurrent connections per domain at six — a real constraint the moment a page needs more than one SSE stream at once, though HTTP/2's multiplexing removes it entirely.

PropertySSE
Directionserver → client only
Transportplain HTTP, no protocol upgrade
Reconnectautomatic, built into the browser, with Last-Event-ID resume
Payload typetext (UTF-8) only
Client library needednone — native EventSource
Per-connection cost~20KB memory, held for the connection's lifetime
Key insight. SSE's real contribution is not the wire format — it is that reconnection and gap-filling are solved once, in the browser, correctly, instead of every team re-implementing their own retry-and-catch-up logic on top of long polling. The id: field and Last-Event-ID header are a complete, standardized resume protocol, and Chapter 7's much harder reconnection-storm problem builds directly on this same mechanism rather than inventing a new one.

Concept → realization: what the server actually does per event

Strip away the framing and an SSE handler is a short-lived amount of code doing one thing on connect and one thing per event, forever:

python — a minimal SSE handler, one stream per viewer
async def stream_comments(request, stream_id):
    resp = StreamingResponse(media_type="text/event-stream")
    last_id = request.headers.get("Last-Event-ID")
    if last_id:
        for ev in ring_buffer[stream_id].since(last_id):   # catch-up replay
            await resp.send(ev.to_sse())
    subscription = connection_registry.add(stream_id, resp)  # Ch. 6's bookkeeping
    try:
        while True:
            event = await subscription.next_event()      # blocks — no CPU spent waiting
            await resp.send(event.to_sse())
    finally:
        connection_registry.remove(subscription)              # on disconnect, drop the socket

The critical line is await subscription.next_event() — the connection consumes essentially zero CPU while idle, parked on an event-loop primitive rather than a busy-polling loop. This is the event-loop architecture Chapter 1 argued was unavoidable for holding a million connections; here is what it looks like once it exists.

Load balancing SSE: easier than it looks, for one specific reason

Unlike the sticky-routing problem Chapter 3 spends real time on, a plain SSE connection can often sit behind an ordinary round-robin load balancer with no special handling — the moment a client's GET /stream/comments lands on server X and gets a 200 with Content-Type: text/event-stream, the TCP connection itself is already pinned to X for the rest of its life; there is no separate “upgrade” handshake that could route differently than the data that follows it. The LB only has to make one decision, at connect time, and never has to remember it afterward. WebSockets, covered next, add a wrinkle to exactly this point.

SSE versus long polling, the numbers side by side

PropertyLong polling (25s cap)SSE
Steady-state QPS~108,900~0 (after initial connect)
Concurrently held connections~1,000,000~1,000,000
Overhead per real delivery~871 bytes~0 bytes (no re-request)
Missed-message handlingnone built in — must be hand-rolledLast-Event-ID, standardized
Works through restrictive proxiesusually, since it looks like ordinary HTTPusually, same reason

Both approaches converge on holding roughly a million connections open — the number Chapter 1 showed was unavoidable once real-time delivery at this scale is the goal. SSE simply stops paying for the 108,900 QPS of connection churn that buys nothing once you have already accepted the memory cost of holding everything open.

What to watch once SSE is live

MetricWhat a problem looks like
Connections per box vs. the 800,000 memory ceilingapproaching it means the next viewer spike causes OOM kills, not graceful degradation
Ring buffer replay ratea rising rate of clients needing replay means reconnects are happening more often than expected — investigate before assuming it is normal churn
Time-to-first-byte on new connectionsshould be near-instant; a rising number means the connect-time handshake path (not the steady-state stream) is under load

Stress-testing the reconnect-delay assumption

The 500-event, 75KB ring buffer above was sized against a 12.5-second reconnect window. That number came from “typical” blips — DNS cached, TLS resumed. It is worth checking what a longer, less typical delay costs, still at the 40/sec hype rate:

Reconnect delayEvents missedBuffer neededCovered by the 500-event buffer?
1 sec (fast, cached TLS)4040yes, with huge margin
12.5 sec (this chapter's design point)500500yes, exactly
30 sec (cold DNS + full TLS handshake)1,2001,200no — 700 events silently lost
90 sec (a real outage, not a blip)3,6003,600no — badly short

Past roughly 12.5 seconds, this chapter's buffer quietly stops being a guarantee. That is not a flaw in the ring-buffer idea — it is the honest boundary of what a small, fixed-size buffer can promise, and it is precisely the gap Chapter 7 closes, once reconnection delay is driven by a real fleet-wide event (a crashed connection server, not a phone switching networks) rather than an ordinary network hiccup.

Why not just use SSE for everything and skip WebSockets entirely

It is a fair question at this point, since SSE has solved memory cost, load balancing, and reconnection all without needing a protocol upgrade. The honest answer is that SSE's one-way constraint is free exactly when the client rarely needs to send anything back quickly — true for a comment feed's display half, but real chat products layer in features SSE cannot carry on the same channel: a typing indicator that needs to appear within tens of milliseconds of a keystroke, a live reaction burst, a presence signal showing who else is watching right now. Every one of those needs a fast client→server path, and routing it through separate POST requests adds exactly the per-request tax Chapters 0 and 1 spent this whole lesson trying to avoid. Chapter 3 covers the protocol built for that case — and prices its actual added complexity honestly, rather than reaching for it by default.

A quick sanity check: does SSE actually beat the do-nothing baseline from Chapter 0

Chapter 0 closed with a stark comparison — naive full-page reloads at 800 Tbps versus a disciplined polling endpoint. Put SSE on the same scale, at the 40/sec hype rate:

ApproachSteady-state bandwidthvs. full-page reload (800 Tbps)
Naive full-page reload, 2s interval800 Tbpsbaseline
Short polling, dedicated endpoint~2.6 Gbps (waste) + content~300,000× smaller
SSE, hype-moment content only48 Gbps (all of it real content, no waste)~16,700× smaller

Read that table carefully: short polling's raw number looks smaller than SSE's, but almost every byte of SSE's 48 Gbps is an actual comment reaching an actual viewer, while short polling's number is nearly all wasted confirmation-of-nothing on top of a separately-paid content cost. Comparing architectures by total bytes moved, without asking what fraction of those bytes were useful, is exactly the kind of shortcut that hides the real tradeoff.

Where SSE leaves this lesson. A real push channel, held open, self-healing on reconnect, at a memory cost of roughly 20KB per viewer. It has one hard limit — one direction only — that costs nothing when the use case doesn't need the other direction, and something real when it does. Chapter 3 builds the full-duplex version and is honest about exactly what that duplex buys and what it costs.

One more failure mode worth naming: the silently half-open connection

Not every dropped SSE connection announces itself. A laptop closing its lid mid-stream, a mobile device losing signal in a tunnel, or a NAT device silently expiring an idle mapping can all leave the server believing a connection is still live — occupying its slot in the 800,000-per-box memory budget — long after the browser on the other end is gone. Left unchecked, that accounting drift eats real capacity: a box that thinks it has 200,000 free connection slots but actually has 50,000 genuinely dead ones sitting in memory will refuse new viewers well before its true headroom is exhausted. The fix is the same heartbeat mechanism Chapter 3 covers for WebSockets — a periodic comment (or an SSE comment line, prefixed with a colon, that the EventSource API ignores entirely) sent down every open stream, with connections that fail to receive several in a row marked dead and reclaimed.

Total ring-buffer memory, fleet-wide

Chapter 5 later assumes the platform runs 10,000 concurrent streams. Only a small fraction of those run anywhere near tonight's 40/sec hype rate — most are quiet, ordinary streams. Price the ring buffer honestly across a realistic mix, all sized for the same 12.5-second reconnect window: 9,900 quiet streams at 0.1/sec, and 100 lively streams at 40/sec.

quiet: 9,900 × (0.1/sec × 12.5 sec × 150 bytes) = 9,900 × 187.5 bytes ≈ 1.86 MB
lively: 100 × (40/sec × 12.5 sec × 150 bytes) = 100 × 75,000 bytes = 7.5 MB
total: 1.86 MB + 7.5 MB ≈ 9.4 MB, fleet-wide, for every stream's ring buffer combined

Under ten megabytes of ring-buffer memory for the platform's entire stream catalog, combined — a rounding error next to the gigabytes of connection-state memory a single box budgets under this chapter's 20KB-per-connection figure. The ring buffer was never the risk in this design; naming its actual size just confirms that plainly instead of leaving it an unpriced assumption.

What a longer reconnect-timeout assumption costs the buffer

Redo the sizing math above at a longer design point — a 30-second reconnect window instead of 12.5 seconds, still for the 100 lively streams — using the same formula this chapter already established.

100 × (40/sec × 30 sec × 150 bytes) = 100 × 180,000 bytes = 18 MB

Doubling the design window roughly doubles the buffer cost, as the linear formula predicts — still trivial in absolute terms. The real cost of a longer reconnect window was never memory; it was the 700-events-lost gap this chapter's earlier stress table already showed for windows past 12.5 seconds. A bigger buffer is cheap to buy; it just cannot fix a reconnect delay the buffer wasn't sized for in the first place.

A connection server budgets 16GB of RAM for connection state at 20KB per idle SSE connection, suggesting 800,000 connections per box. Why is this number, by itself, not enough to decide how many boxes are needed for 1,000,000 viewers during a hype moment?

Chapter 3: WebSockets

The comment box is about to grow a feature: a small “3 people typing…” indicator above the input, and a live count of reactions flying across the screen. Both need something SSE cannot give — the client sending frequent, low-latency messages to the server, on the same connection that is already receiving comments. That is exactly the gap WebSockets exist to close: one TCP connection, opened once, carrying independent message streams in both directions at once — full-duplex, in the terminology this chapter uses throughout.

The handshake: an HTTP request that changes its own protocol

A WebSocket connection is born as an ordinary HTTP request and then transforms mid-flight. The client sends a normal GET with two special headers:

http — the WebSocket upgrade handshake
GET /stream/comments HTTP/1.1
Host: live.example.com
Upgrade: websocket                          // "I want to switch protocols"
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==  // a nonce, proves this wasn't cached
Sec-WebSocket-Version: 13

// the server agrees:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=  // hash of the nonce, proves it's a real WS server

// same TCP connection, now speaking WebSocket frames in both directions

After the 101 response, the underlying TCP connection never closes — it simply stops speaking HTTP and starts speaking a much smaller binary framing protocol: a short header (a few bytes, encoding frame type and payload length) followed by the payload itself, which can be text or raw binary. That binary-frame support is one real advantage SSE does not have: a compact binary comment encoding, or a small binary reaction-burst packet, needs no base64 inflation here.

What one WebSocket connection costs

Structurally, a WebSocket connection costs about the same as SSE's ~20KB — TCP buffers, TLS state, a subscription record — plus a modest addition for the inbound direction's read buffer and the small state machine tracking in-progress frame parsing, typically another 5–10KB. Call it 28KB per connection as a working figure, and redo Chapter 2's memory math with a 16GB budget:

16,000,000 KB ÷ 28 KB ≈ 571,000 connections per box, by memory

Down from SSE's 800,000 to about 571,000 — a real cost, about 29% fewer connections per box for the same memory budget, purely from carrying a channel most viewers will barely use. Whether that tradeoff is worth it is exactly Chapter 4's question, not this one's; this chapter's job is only to make sure the cost is priced honestly before that decision gets made.

WebSocket connection memory vs. SSE, at a fixed RAM budget

Drag the RAM budget dedicated to connection state on one box. Compare how many connections that buys under SSE's ~20KB figure versus WebSocket's ~28KB figure.

RAM budget (GB)16

The sticky-routing problem

Here is the cost that has nothing to do with memory. A stateless HTTP API can be load-balanced request by request — each incoming request can land on any healthy backend, since no backend holds state between requests. A WebSocket connection breaks that assumption at its root: the moment the upgrade handshake completes on server X, every subsequent message for that session — comments flowing out, typing-indicator pings flowing in — must physically travel over that same TCP connection to that same server X, because that is the only place the connection, and the in-memory subscription state tied to it, actually exists.

This is sticky routing, and an ordinary Layer-4 (TCP-level) load balancer gets it for free, almost by accident: an L4 LB forwards packets for an already-established connection to wherever it first sent that connection's SYN, for the life of the TCP session — it has no choice but to be sticky, because it is not inspecting individual messages, only routing a stream of packets that belong to one connection. The trouble starts at scale-events: if server X is drained for a deploy, or crashes, every client whose socket lived there loses its connection, and the LB's stickiness guarantee (this connection stays on X) becomes meaningless, because X is gone. The client reconnects, lands on a different server Y via a fresh handshake, and Y has no idea what that client's subscription state used to be — it is a stranger with a blank slate. Chapter 5's pub/sub layer and Chapter 6's connection ring exist specifically to make that reconnection cheap and correct instead of catastrophic.

Heartbeats: keeping a connection provably alive

An idle TCP connection is invisible to the two endpoints holding it — from the server's perspective, a socket with no traffic on it for an hour looks identical to a healthy, quiet client. The problem is everything between those two endpoints: home routers, corporate proxies, and mobile carrier NATs commonly expire idle connection mappings after as little as 60 seconds of silence, silently dropping the mapping without telling either side. The client believes it is still connected; the server believes the same; the connection is dead.

WebSocket defines small control frames — ping and pong — exactly to defeat this. The server (or client) sends a tiny ping frame periodically; the other side is required to answer with a pong. Silence through several expected pongs means the connection is actually dead, not just quiet, and it gets reclaimed. Pick the interval against the tightest plausible NAT timeout with real margin — half of 60 seconds, rounded to a clean number that matches Chapter 1's long-poll timeout:

heartbeat interval ≈ 25 sec (well under the ~60s worst-case idle timeout)

Cost that fleet-wide. Every one of 1,000,000 connections sends one ping and receives one pong every 25 seconds, at roughly 6 bytes per control frame:

(1,000,000 × 2 frames) ÷ 25 sec = 80,000 frames/sec × 6 bytes = 480,000 bytes/sec ≈ 3.8 Mbps

Compare against Chapter 0's 48 Gbps hype-moment content bandwidth: heartbeat traffic is roughly 1/12,500th of it — a rounding error, bought at the price of a guarantee that every “connected” socket in the fleet's bookkeeping is actually connected.

PropertyWebSocket
Directionfull-duplex — both sides send whenever they want
HandshakeHTTP Upgrade to a persistent binary-framed connection
Payload typetext or raw binary
Per-connection cost~28KB, versus SSE's ~20KB
Load balancingsticky by construction (L4), but reconnects land on a stranger server with no shared state
Reconnectionnot automatic — must be hand-rolled client-side, unlike SSE's built-in retry

Concept → realization: the two-way handler

python — a minimal WebSocket handler, both directions
async def comment_socket(ws, stream_id):
    await ws.accept()
    subscription = connection_registry.add(stream_id, ws)
    heartbeat_task = asyncio.create_task(send_pings(ws, interval=25))
    try:
        while True:
            done, _ = await asyncio.wait(
                [ws.recv(), subscription.next_event()],       # both directions, one loop
                return_when=asyncio.FIRST_COMPLETED)
            for task in done:
                result = task.result()
                if result.is client_message:            # inbound: typing indicator, reaction
                    await presence_bus.publish(stream_id, result)
                else:                                    # outbound: a new comment to deliver
                    await ws.send(result.to_frame())
    finally:
        heartbeat_task.cancel()
        connection_registry.remove(subscription)

The shape of the loop is the whole story: one asyncio.wait watching two independent sources — inbound frames from the client, outbound events from the subscription — and reacting to whichever arrives first. SSE's handler from Chapter 2 only ever had the outbound half.

When not to reach for WebSockets

None of the above is free, and the honest recommendation depends entirely on what the client actually needs to send. If the only client-originated action is posting a comment — rare, one-off, entirely tolerant of a normal HTTP POST's latency — a WebSocket buys full duplex the application never uses, while still paying the extra 8KB/connection and inheriting the sticky- routing complexity above. SSE plus plain POST is simpler to operate, easier to debug through restrictive corporate proxies that do not reliably support the Upgrade handshake, and gets built-in reconnection for free. WebSockets earn their cost specifically when the client needs to send frequently and fast — typing indicators, live cursors, multiplayer input, presence pings — where routing every one of those through a fresh POST would reintroduce the exact per-request tax this whole lesson exists to eliminate. Chapter 4 turns this paragraph into a decision procedure with real numbers instead of a judgment call.

The honest cost of full duplex. About 40% more memory per connection than SSE, sticky-routing infrastructure that plain round-robin balancing does not need, and hand-rolled reconnection logic instead of a free one. In exchange: a client→server channel with no per-message request tax. That exchange is a clear win exactly when the client actually needs it, and a needless cost exactly when it does not.

Stress-testing the heartbeat interval

The 25-second interval assumed a worst-case NAT timeout of 60 seconds. That figure varies across real networks — some carriers are far more aggressive. Check the interval against a spread of assumptions, keeping the “interval ≤ half the timeout” safety margin fixed:

Assumed NAT/proxy idle timeoutSafe heartbeat intervalFleet-wide overhead
120 sec (generous, home broadband)60 sec~1.6 Mbps
60 sec (this chapter's design point)25 sec~3.8 Mbps
30 sec (an aggressive mobile carrier)12 sec~8 Mbps

Even the most aggressive row costs under 10 Mbps fleet-wide — still five orders of magnitude below the 48 Gbps hype-moment content figure. Heartbeat overhead is one of the few numbers in this lesson that stays negligible no matter how conservatively it is tuned, which is why production systems generally pick the aggressive end of this table without much debate: there is essentially no cost to being safe here.

A debugging note worth saving real time on

The single most common WebSocket production failure has nothing to do with any of the math above: a corporate proxy, an older enterprise firewall, or a misconfigured intermediate load balancer strips the Upgrade header or does not understand the 101 Switching Protocols response, and the connection silently falls back to failing, or hangs. Because this happens entirely at the network layer, application logs show nothing wrong — the client simply never connects, for a specific subset of users behind a specific kind of network intermediary, while everyone else works fine. The practical fix is a documented fallback: detect a failed upgrade attempt within a short timeout and fall back to Chapter 2's SSE (or, in the most restrictive networks, Chapter 1's long polling), rather than leaving that user with nothing.

Why not just open a raw TCP socket and skip HTTP entirely

It is a fair question, since WebSocket's handshake is arguably just ceremony around what is, after the upgrade, a plain bidirectional TCP stream. The answer is almost entirely about the environment the connection has to survive, not the protocol itself: browsers do not expose raw TCP sockets to web pages at all, for security reasons, so a raw-socket approach is a non-starter for any browser client. Beyond the browser, WebSocket's HTTP-shaped handshake lets it pass through the exact same firewalls, proxies, and load balancers that already understand port 443 and TLS, whereas an arbitrary custom TCP protocol on its own port is far more likely to be blocked outright by infrastructure that was never configured to expect it.

What to watch once WebSockets are live

MetricWhat a problem looks like
Missed-pong raterising steadily means dying connections are not being reclaimed fast enough, inflating the fleet's believed connection count above its real one
Upgrade failure rate, by client networka spike isolated to specific ASNs or countries usually means a proxy stripping the Upgrade header, not an application bug
Reconnect rate after a deployshould spike briefly and recover; a sustained elevated rate means the new server population is rejecting handshakes it shouldn't
Inbound-vs-outbound frame ratioa sudden shift toward inbound-heavy traffic can signal a client-side bug spamming presence pings, worth capping server-side

None of these four metrics existed in Chapter 2's SSE monitoring table, and that difference is the real summary of this chapter: WebSockets do not just add capability, they add an entire second direction of things that can silently go wrong, each needing its own signal to catch early.

Recomputing the fleet size with the 28KB figure

Carry the corrected per-connection cost through Chapter 0's box-count exercise one more time, purely on memory, before Chapter 6 adds the CPU constraint that actually decides it:

1,000,000 viewers ÷ 571,000 conns/box ≈ 1.752 boxes, by memory

Still just 2 boxes by memory alone, whether the protocol is SSE or WebSocket — the 28-versus- 20KB difference moves the memory-only estimate from 2 boxes to 2 boxes, because both round up from well under a millionth of the real ceiling. That is worth sitting with for a second: an engineer who stops at the memory calculation would conclude protocol choice barely matters for fleet size. Both Chapter 2 and this chapter deliberately flagged that conclusion as incomplete, and Chapter 6 is where the actual, much larger number gets derived.

connect
client sends HTTP Upgrade · server responds 101 · TCP connection now speaks WS frames
subscribe
server registers this socket against the viewer's stream id in the connection registry (Ch. 6)
steady state
inbound: rare typing/reaction frames · outbound: comment frames as they're published · ping/pong every 25s
disconnect
client closes, or several missed pongs · registry entry removed · slot freed

Four steps, and notice the second one already leans on machinery this chapter has only gestured at — a “connection registry” that knows which stream each socket belongs to, and a way for a comment published somewhere else in the fleet to actually reach this specific socket. Chapter 5 builds that machinery properly; this chapter's job was only to nail down what a single connection costs and what it is capable of, in isolation, before wiring a whole fleet of them together.

A note on TLS termination and its cost

Every number in this chapter assumed a connection was already secured, without pricing the security itself. TLS termination — decrypting inbound traffic and encrypting outbound — costs real CPU on top of everything else a connection server does, though modern hardware with AES-NI instruction support makes the steady-state cost per byte small enough to ignore next to the handshake cost. The handshake itself is the expensive part: a full TLS 1.3 handshake costs roughly similar CPU to the write-cost figures Chapter 6 derives, which is exactly why Chapter 7's reconnection-storm math treats handshake capacity, not steady-state encryption, as the resource that actually limits how fast a fleet can absorb a wave of simultaneous reconnects. Session resumption (a TLS feature letting a reconnecting client skip most of the handshake by presenting a cached session ticket) cuts that cost substantially for exactly the reconnect-storm case this lesson's final chapters care most about.

Pricing the TLS handshake, digit by digit

The previous paragraph named the handshake as the expensive part without pricing it. Do that now. A full TLS 1.3 handshake, including the asymmetric key exchange, costs roughly 1–2ms of CPU on modern hardware with AES-NI support — call it 1.5ms as a working figure. During ordinary, non-storm connection churn — clients naturally reconnecting after brief network blips, at a background rate of roughly 1% of the fleet per minute — compute the handshake CPU cost fleet-wide:

1,000,000 × 0.01 = 10,000 reconnects/min ≈ 167 reconnects/sec
167/sec × 1.5ms = 0.25 core-seconds of CPU per second, fleet-wide

A quarter of one CPU core, fleet-wide, spent on ordinary handshake churn — utterly negligible against the thousands of cores this lesson's connection tier runs. Session resumption, which lets a reconnecting client skip the expensive asymmetric step by presenting a cached session ticket, cuts that already-tiny number further, to well under a millisecond of CPU per resumed handshake. The number only becomes interesting during a real reconnection storm, which is exactly where Chapter 7's handshake-capacity ceiling (2,000/sec per box) comes from — that figure already has TLS cost baked into it, not layered on top as an afterthought.

ScenarioReconnect rateHandshake CPU, fleet-wide
Ordinary background churn~167/sec~0.25 core-seconds/sec
A 5-box reconnection storm (Chapter 7)~100,000 within ~1 sec~150 core-seconds/sec (spread across 45 surviving boxes)

Even the storm row spreads to roughly 3.3 core-seconds per surviving box per second — real, but well inside a 16-core box's budget once the 2,000/sec-per-box handshake ceiling from Chapter 7 is already respected. TLS cost turns out to be a real but secondary constraint, folded into the handshake-rate ceiling rather than a separate bottleneck of its own.

An L4 load balancer keeps a WebSocket connection sticky to one server for its whole lifetime almost by accident. What actually happens when that server is drained for a deploy?

Chapter 4: Choosing a Protocol

Three real chapters, three real transports, three sets of honestly-derived numbers. It is tempting to pick a favorite on vibes — WebSockets feel modern, SSE feels simple, polling feels old-fashioned — and production systems that pick this way regret it later, once a choice made for the wrong reason meets a scale it wasn't priced for. This chapter turns Chapters 1 through 3's numbers into an actual decision procedure.

The four options, every number in one place

PropertyShort poll (2s)Long poll (25s)SSEWebSocket
Steady-state QPS500,000~108,900~0~0
Concurrently held conns~0~1,000,000~1,000,000~1,000,000
Memory per conn (held)n/aa few KB (event-loop)~20KB~28KB
Avg latency to new comment~1 secnear-immediatenear-immediate (network only)near-immediate (network only)
Client → server channelseparate POSTseparate POSTseparate POSTsame connection
Built-in reconnect + resumen/a (stateless)no — hand-rolledyes — Last-Event-IDno — hand-rolled
Restrictive-proxy friendlinessexcellentexcellentgoodcan be blocked by Upgrade-stripping proxies

Three questions, in order

Every one of those seven rows collapses into three practical questions, asked in this order, because each one eliminates options faster than the last:

1 · does the client need to send fast, frequent messages back?
yes → WebSocket is the only option with no per-message request tax · no → continue
2 · can the infra afford ~1M held-open connections?
yes → SSE, cheaper and simpler than long polling · no (very restrictive environment, few clients) → continue
3 · is sub-second latency actually required?
yes → long polling, accepting its connection-holding cost · no → short polling is the simplest thing that could possibly work
Protocol chooser — pick a scenario, read the live numbers

Click a protocol. The diagram highlights its path and the readout below recomputes QPS, held connections, and latency for the client count and comment rate set by the sliders.

viewers1,000,000
comment rate (/sec)40.0

Why the procedure has exactly three questions, not one

It is tempting to collapse this into a single rule of thumb — “use WebSockets, they can do everything the others can” — and every earlier chapter's arithmetic explains precisely why that collapse is a mistake. A single rule cannot distinguish the breaking-news ticker (no inbound channel needed at all) from this lesson's comment feed (a fast inbound channel is the whole point), and it cannot distinguish a 40-user internal tool (where none of Chapters 0 through 3's scaling math even applies) from a million-viewer broadcast (where it is the entire design problem). Three questions is the minimum needed to route correctly between all four options this lesson built, and each one maps to a chapter's core finding: question 1 to Chapter 3's duplex cost, question 2 to Chapters 2 and 3's memory math, question 3 to Chapter 1's latency-versus-QPS tradeoff.

Four worked scenarios

ScenarioAnswers to the three questionsChoice
This lesson's comment feed, with typing indicatorsfast client→server: yesWebSocket
A read-only breaking-news ticker, no client input at allfast client→server: no · can afford held conns: yesSSE
An internal dashboard behind a strict corporate proxy that blocks Upgradefast client→server: no · can afford held conns: no (proxy limits)long polling
An admin tool with 40 total users, refreshed a few times a minutefast client→server: no · can afford held conns: n/a at this scale · sub-second latency: not requiredshort polling — simplest thing that works
The same comment feed, but the viewer is a commuter on a lossy, high-latency mobile connection dropping in and out of coverage every few secondsfast client→server: yes (typing indicators still wanted) · can afford held conns: yesWebSocket — same choice as row 1; this scenario's real design pressure lands on Chapter 7's reconnect robustness, not on protocol choice

That fourth row is worth pausing on: short polling is not universally wrong, it is wrong at this lesson's scale. Forty users polling every few seconds costs a rounding error's worth of QPS — the entire case against it in Chapters 0 and 1 was about what happens when the client count reaches a million, not about polling being inherently bad engineering.

The fifth row is worth reading differently from the first four: the three-question procedure gives the exact same answer — WebSocket — whether the viewer has a rock-solid connection or a flaky one, because the questions this chapter asks are about what the client needs to send, not about how reliable the network happens to be. A high-latency, lossy client does not change which protocol to choose; it changes how much the resume-token and jittered-reconnect machinery from Chapters 2 and 7 actually gets exercised in practice, once that protocol is running. Two viewers on the exact same architecture can have wildly different experiences of its resilience layer without the underlying design decision differing at all.

Quantify the difference plainly: a subway commuter whose connection blips every 8 seconds is not straining Chapter 6's steady-state connection-tier sizing at all — one flaky viewer looks identical to the fleet as one more entry in the reconnect-rate metric from Chapter 3's watchlist, spread thin across millions of ordinary background reconnects. It only becomes a capacity question if enough viewers share that same flaky pattern simultaneously — a whole subway car losing signal in a tunnel at once — which is a small-scale version of exactly the reconnection storm Chapter 7 prices in full.

Cost per real byte delivered, all four, one table

Chapter 1 computed overhead-per-delivery for short and long polling. Complete the comparison at the 40/sec hype rate, 1,000,000 viewers:

ProtocolOverhead per real deliveryWhat that overhead buys
Short polling~4,000 bytessimplicity, zero held-connection cost
Long polling~871 byteslower latency, still zero-protocol-upgrade infra
SSE~0 bytes (no re-request)built-in resume, one-way only
WebSocket~0 bytes, plus ~40% more memory/connthe above, plus a fast return channel

Read the table right to left: every step down adds a real capability at a real, quantified cost. None of the four options is strictly better than the others in every column — which is exactly why a decision procedure, not a default, is the right tool.

Key insight. There is no universally “best” realtime transport, and treating one as a default is how systems end up over-engineered (WebSockets for a ticker nobody talks back to) or under-engineered (short polling surviving past the point where its request tax melts the fleet). The right tool is a function of exactly two things: does the client need to talk back quickly, and how many connections can the infrastructure afford to hold open. This lesson's live-comments case answers “yes” and “a lot,” which is why the rest of the lesson builds on WebSockets specifically — not because it is the best protocol in the abstract, but because it is the right one for this exact problem.

A note on mobile clients specifically

Everything in this chapter's decision procedure assumes a client that can hold a connection open indefinitely, and mobile devices complicate that assumption in a way worth naming. Backgrounding an app commonly suspends its network activity within seconds, silently breaking whichever protocol was chosen — a long poll, an SSE stream, or a WebSocket all die identically the moment the OS suspends the process. The practical answer on mobile is rarely “pick a different realtime protocol” but rather “pair the realtime protocol with a push-notification fallback,” so a viewer who backgrounds the app during a hype moment still gets a native OS notification for something worth surfacing, even though their WebSocket connection has already been silently terminated by the operating system. That fallback sits entirely outside this chapter's four options and is worth flagging so a mobile client is not designed as if it behaves like a desktop browser tab.

Concept → realization: a client that degrades gracefully

A production client rarely commits to exactly one protocol and gives up if it fails. It tries the richest option the environment allows and falls back, in order, to something that still works:

javascript — a fallback chain, richest to simplest
async function connectRealtime(streamId) {
  try {
    const ws = new WebSocket(`wss://live.example.com/stream/${streamId}`);
    await withTimeout(waitForOpen(ws), 3000);   // upgrade must succeed within 3s
    return wrapAsFullDuplex(ws);
  } catch (e) {
    // Upgrade blocked, or timed out — proxy likely stripped it
  }
  try {
    const es = new EventSource(`/stream/${streamId}`);
    return wrapWithSeparatePost(es);            // SSE in, POST out
  } catch (e) {
    // EventSource unsupported or also blocked — rare, but happens
  }
  return longPollFallback(streamId, { timeoutMs: 25000 });  // last resort, always works
}

Every fallback step in that chain corresponds to a row in this chapter's decision table — the client is running the decision procedure live, per-user, based on what their specific network actually permits, rather than the team guessing once at design time what every viewer's network will allow.

Migrating an existing feature without downtime

A team currently running short polling and wanting to move to WebSockets faces a real operational question: how do you cut over 1,000,000 already-connected clients without a synchronized reconnect storm of exactly the kind Chapter 7 studies? The answer is a staged rollout, not a flag flip. Version the client so it can speak either protocol, ship it behind a percentage rollout — 1%, then 10%, then 50%, then 100% — and watch the four monitoring metrics from Chapters 2 and 3 at each stage before advancing. A migration that moves 1,000,000 clients over minutes, in controlled increments, looks nothing like the same 1,000,000 clients dropping and reconnecting at once, and the difference is entirely in how the rollout is paced, not in the destination protocol itself.

Why the decision procedure asks about the client first, not the scale

It would be tempting to reorder the three questions by scale — check viewer count first, then worry about bidirectional needs. That ordering produces the wrong answer for the admin-dashboard scenario in the table above: at 40 users, viewer count alone says “anything works, don't overthink it,” and a scale-first procedure would stop there and never ask whether the client needs to talk back. The bidirectional question has to come first because it is the one question whose answer does not change with scale — a feature that needs a fast return channel needs it whether there are 40 users or 40,000,000, while the connection-affordability question is the one that actually depends on scale, and belongs second.

A quick reference: the same procedure as a lookup table

Fast bidirectional need?Can afford ~1M held conns?Sub-second latency required?Answer
YesWebSocket
NoYesSSE
NoNoYesLong polling
NoNoNoShort polling

Read as a lookup table, the procedure collapses to four rows and reproduces every scenario in this chapter without needing to re-derive Chapters 1 through 3's numbers each time. That is the entire purpose of doing the arithmetic once, properly, in those earlier chapters: this table is what gets consulted in a real design review, while the derivations behind each row are what get consulted the one time someone asks “wait, why is that the answer?”

What getting this decision wrong actually costs, in two directions

It helps to make both failure directions concrete, not just abstract. Picture the breaking-news ticker from the scenario table shipped with WebSockets instead of SSE, because a previous team defaulted to it everywhere. Every one of its million connections now costs 28KB instead of 20KB — an extra 8GB of connection memory across the fleet for a feature that never uses the return channel at all — and every deploy now has to worry about sticky-routing session loss on reconnect, a failure mode that literally cannot happen to a feature with no inbound direction in the first place. That is real operational cost purchased for a capability nobody uses.

Now picture the opposite mistake: this lesson's comment-plus-typing-indicator feature shipped on short polling because it was already lying around from an older version of the product. Chapter 0's math applies without modification — 500,000 QPS of mostly-empty requests competing with every other feature on the same API tier, and the typing indicator itself is now impossible to build well at all, since “is someone typing right now” needs sub-second round trips that a 2-second poll interval fundamentally cannot deliver. One mistake wastes memory and adds needless complexity; the other mistake makes a whole feature category impossible to build correctly. Neither is free, and knowing which mistake a given design decision risks is most of the value in the three-question procedure above.

A note on HTTP/2 and HTTP/3: does this all change again

Everything in this chapter assumed the connection-limit and multiplexing behavior of HTTP/1.1, since it is still the most conservative assumption a system has to survive in practice. HTTP/2 multiplexes many logical streams over one physical TCP connection, which removes the six-connections- per-domain ceiling mentioned in Chapter 2 and makes running several SSE streams from one page trivial. HTTP/3, built on QUIC instead of TCP, changes the transport layer enough that a dropped WiFi-to-cellular handoff can sometimes survive without even a visible reconnect, since QUIC connections are identified by a connection ID rather than a TCP four-tuple. None of that changes this chapter's three-question decision procedure — it only makes the “can the infra afford held-open connections” question answer “yes” more often, at a lower cost, as these protocols see wider deployment across the actual devices a product has to support.

Testing the decision before committing a fleet to it

None of the numbers in Chapters 0 through 3 need to be trusted blindly before shipping — every one of them is measurable in a load test before a single real viewer connects. Stand up a synthetic client harness that opens connections at a controlled rate, matching whichever protocol the decision procedure selected, and drive it toward the target viewer count while watching exactly the monitoring metrics each chapter named: empty-response rate for polling, ring-buffer replay rate for SSE, missed-pong rate for WebSockets. A load test that only checks “did requests succeed” misses the entire point of this lesson — the failure mode here is never a clean error, it is a queue that quietly grows, a connection count that quietly climbs past a memory budget, or a heartbeat interval that quietly stops being safe margin. Catching those requires watching the same specific numbers this lesson derived by hand, not a generic uptime check.

Concept → realization: protocol negotiation on the server side

The client-side fallback chain earlier in this chapter has a server-side mirror — the same endpoint needs to recognize which protocol a given request is asking for and route accordingly, since all three push-capable options can plausibly share one URL:

python — one endpoint, three possible protocols
async def stream_endpoint(request, stream_id):
    if request.headers.get("Upgrade", "").lower() == "websocket":
        return await comment_socket(request, stream_id)          # Ch. 3's handler
    if "text/event-stream" in request.headers.get("Accept", ""):
        return await stream_comments(request, stream_id)          # Ch. 2's handler
    if request.headers.get("X-Long-Poll") == "1":
        return await long_poll_handler(request, stream_id, timeout=25)  # Ch. 1's handler
    return short_poll_handler(request, stream_id)                       # the universal fallback

One route, three request-side signals — an Upgrade header, an Accept header, or a custom marker — each dispatching to the handler this lesson already built in an earlier chapter. No new delivery logic gets written here; this endpoint is purely a router in front of work that already exists.

What this chapter deliberately does not decide

Two questions remain open on purpose, because they are the subject of the rest of this lesson, not this chapter. First: once a protocol is chosen and a comment is published, how does it actually reach the correct held connections, given that the server receiving the write and the server holding the socket are, in general, two different machines? Second: how many connection-holding machines are actually needed, and what resource genuinely limits that number? Chapter 4's decision procedure answers which transport carries the last hop to a browser. It says nothing yet about the fleet behind that transport, and treating this chapter's table as a complete system design would be mistaking one hop for the whole path.

Verified: the decision this lesson makes for its own scenario. Live comments, 1,000,000 concurrent viewers, plus a typing indicator that needs to appear within tens of milliseconds. Question 1 (fast client→server?) answers yes — WebSocket. Chapters 5 through 8 build the fleet behind that choice: the pub/sub layer that gets a comment from wherever it was written to wherever a socket is listening, the connection tier sized by its real bottleneck, and the storm recovery that keeps a chunk of that fleet dying at once from taking the whole system down with it.
An internal admin dashboard has 40 total users, refreshed a few times a minute, behind no unusual network restrictions. What does this chapter's decision procedure recommend, and why?

Chapter 5: Pub/Sub Decoupling

Chapter 0 named four hops in the delivery path and Chapters 1 through 4 spent their entire time on the last one — which protocol carries a comment from a held connection down to one browser. This chapter is about the hop nobody has touched yet, and it turns out to be the one that makes the whole system actually work: how does a comment get from the ordinary API server that received the write to the tens of machines holding a million WebSocket connections, none of which is the server that just handled the POST?

The server that writes has no socket to anyone

Picture the actual request path. A viewer's browser POSTs a new comment to whichever stateless API server a load balancer happened to route it to — call it server R, for “receiver.” Server R writes the comment to the database. It is done. But server R has never held a WebSocket connection to any of the 1,000,000 viewers — those sockets live on an entirely separate fleet, the connection tier, built in Chapters 2 and 3 and sized properly in Chapter 6. Server R has a comment that needs to reach a million sockets it cannot see. Something has to bridge that gap, and it cannot be a direct network call from R to every connection server, because R has no idea which connection servers currently hold sockets for this particular stream, and that set changes every second as viewers join and leave.

The bridge: a message broker

The fix is a message broker — Redis Pub/Sub, Kafka, or an equivalent — sitting between the two fleets. Server R does not talk to connection servers at all. It publishes the comment to a topic. Every connection server currently interested in that stream has already subscribed to that same topic, and the broker delivers the message to each of them. Neither side needs to know the other's identity, IP address, or even that the other exists — that is what decoupling means here, and it is the entire reason this pattern is worth its own chapter instead of being an implementation detail of Chapter 6.

The two-level fan-out, with real numbers

Here is the arithmetic that makes this pattern worth building instead of just a nice diagram. Chapter 6 derives that this lesson's connection tier needs 50 connection servers to hold 1,000,000 viewers. The broker does not deliver a comment directly to 1,000,000 sockets — it delivers to 50 subscribers, one per connection server:

broker-level deliveries = 40/sec × 50 = 2,000 deliveries/sec

Two thousand deliveries a second at the broker level — utterly trivial for Redis Pub/Sub, which comfortably handles well over a million operations a second on modest hardware, or for Kafka, built for exactly this throughput class. Each of those 50 connection servers then performs the second-level fan-out locally, in-process, looping over its own roughly 20,000 held sockets — an in-memory operation with no network round trip per socket, nothing like the cost of 1,000,000 independent broker-to-socket deliveries:

40,000,000 total deliveries = 2,000 (broker → 50 servers) × 20,000 (local fan-out, in-process, per server)

That factorization is the entire value of this chapter. The same 40,000,000-deliveries-a-second reality from Chapter 0 still happens — nothing about the total work disappeared — but it happens as 2,000 cheap network deliveries plus 50 independent local loops, instead of one component somehow performing 40,000,000 network sends by itself.

Two-level fan-out — broker deliveries vs. total deliveries

Drag the connection-server count. Watch broker-level delivery load fall as more servers share the local fan-out, while total deliveries (the Chapter 0 number) stays fixed.

connection servers50

Concept → realization: publish and subscribe, minimal code

python — the receiver publishes, a connection server subscribes
# server R, handling the POST /comments write
async def post_comment(request, stream_id):
    comment = await save_to_db(request, stream_id)
    await broker.publish(topic_for(stream_id), comment.to_wire())  # fire and forget
    return {"status": "ok", "id": comment.id}

# a connection server, on startup, for each stream it holds viewers for
async def watch_stream(stream_id):
    async for raw in broker.subscribe(topic_for(stream_id)):
        for socket in connection_registry.sockets_for(stream_id):  # local loop, in-process
            await socket.send(raw)

Server R's handler never learns which connection servers exist, how many there are, or where they run. The subscribing side never learns which of possibly many API servers a given comment happened to arrive through. That mutual ignorance is not an accident of this small example — it is the actual design goal, and it is what lets the two fleets scale, deploy, and fail independently of each other.

Topic design: one topic per stream, not one topic for everything

It matters enormously how topics are scoped. The naive version — one global all-comments topic carrying every comment on the entire platform — forces every connection server to receive traffic for streams it has zero viewers on, and filter almost all of it out. Cost that honestly. Say the platform runs 10,000 concurrent streams, averaging 500 comments a second combined across all of them. A connection server handling only tonight's megastream, subscribed to a global topic, would receive all 500/sec and discard the roughly 460/sec that belong to streams it holds no viewers for:

(500 (global topic)) ÷ (40 (this stream, scoped topic)) = 12.5× more inbound traffic than necessary

A per-stream topicstream:knockdown-fight:comments, distinct from every other stream's topic — means a connection server only ever receives what it actually needs to deliver. The fix costs nothing beyond choosing the topic name correctly at publish time; it is one of the rare cases in this lesson where the disciplined choice is not even more expensive.

Topic designInbound traffic per connection server (this stream)Wasted fraction
Global (“all-comments”)500/sec92%
Per-stream (this lesson's design)40/sec0%

Subscribing and unsubscribing as viewers move

A connection server does not subscribe to every stream on the platform, or even every stream it has ever seen — it subscribes to exactly the streams it currently holds at least one viewer for, and unsubscribes the moment its last viewer for a stream disconnects. This is dynamic, not a one-time setup: as a stream's popularity rises and falls, the set of connection servers subscribed to it changes continuously. Skipping this and subscribing every connection server to every stream “just in case” reproduces the exact 12.5× global-topic waste computed above, multiplied across every stream on the platform simultaneously — a mistake that looks harmless in a small test environment and becomes very visible the day the platform hosts its second concurrent megastream.

Key insight. Pub/sub does not reduce the total amount of delivery work — Chapter 0's 40,000,000 deliveries/sec still happen, in full. What it does is relocate where that work happens: 2,000 cheap broker deliveries instead of one component attempting 40,000,000 direct network sends, and per-stream topics so no connection server pays for traffic it has no viewers for. Fan-out was never optional. Where it happens was always the decision.

What happens if the broker itself falls behind or goes down

Decoupling server R from the connection tier does not make the broker itself immune to failure, and it is worth being honest about what breaks if it is slow or unavailable. A broker falling behind (consumer lag climbing, from Chapter 0's monitoring table) means comments are published but not yet delivered — viewers see a growing, uniform delay across the whole stream, not a partial outage. A broker that is fully unavailable means server R's publish call itself starts failing; the honest response is to let the comment write still succeed (it is safely in the database) while the publish is retried or dead-lettered, rather than failing the user's comment post because a downstream fan-out component is unhappy. The write path and the fan-out path are decoupled enough that one failing does not have to take the other down with it — which is, again, the entire point of putting a broker between them in the first place.

Choosing a broker: Redis Pub/Sub versus Kafka

Both are real options, and they trade off differently for exactly this workload. Redis Pub/Sub is fire-and-forget: a message published while a subscriber is briefly disconnected is simply gone for that subscriber, with no replay. For live comments, that is an acceptable loss — a reconnecting connection server picks back up with the live stream, and Chapter 7's resume-token mechanism (operating one layer further down, at the individual viewer's socket) is what actually guarantees no comment is missed end-to-end, not the broker. Kafka, by contrast, persists every message to a log and lets a slow or reconnecting consumer replay from wherever it left off — useful when durability matters more than the operational simplicity of a pure in-memory broker, at the cost of running and tuning a heavier piece of infrastructure.

PropertyRedis Pub/SubKafka
Delivery if subscriber briefly downlost — no replayreplayable from the persisted log
Operational weightlightweight, already likely in the stack (Chapter 4's cache tier)heavier — brokers, partitions, consumer group coordination
Throughput headroom at this scale (2,000/sec)enormous marginenormous margin
Right fit for this lesson's comments feedyes — loss is tolerable, resume tokens cover the gapoverkill here, better suited to durable event logs

Ordering: does a viewer ever see comments out of sequence

One more property worth pinning down: within a single stream's topic, does every connection server see comments in the same order they were published? A single-partition topic (the natural choice at 2,000 deliveries/sec, nowhere near needing to shard a topic for throughput) preserves publish order end to end — every subscriber sees the same sequence server R published in. What is not guaranteed across a fleet of many API servers (R1, R2, R3, …) all publishing to the same stream's topic concurrently is a total order across different comments arriving at nearly the same instant on different receivers — two comments posted within the same millisecond by two different users, handled by two different API servers, can interleave in either order. For a comments feed, that is invisible to any human reader; it would matter far more for a use case with strict causal ordering requirements, which this one is not.

1 · write
viewer POSTs a comment · any stateless API server (R1, R2, …) handles it · saved to the database
2 · publish
that same server publishes to stream:<id>:comments · ~2,000 deliveries/sec fleet-wide
3 · subscribe (already established)
every connection server holding a viewer for this stream is already subscribed to this exact topic
4 · local fan-out
each of the ~50 connection servers loops its own ~20,000 sockets, in-process, no network hop per socket

A worked stress test: what happens if the stream gets ten times bigger

Run this chapter's numbers against the largest row of Chapter 0's viewership sensitivity table — a once-a-decade event at 3,000,000 concurrent viewers, still at the same 40/sec comment rate. Chapter 6 would size the connection tier at 150 servers instead of 50 (the same 20,000-per-server ceiling, applied to three times the viewers). Recompute broker-level load:

40/sec × 150 = 6,000 broker deliveries/sec

Still trivial for either Redis Pub/Sub or Kafka — the broker was never going to be the bottleneck for this workload, at any viewership this lesson considers. That is worth stating explicitly, because it means scaling this system is almost entirely a connection-tier problem (Chapter 6) and a reconnection-resilience problem (Chapter 7), not a message-broker capacity problem. The broker's job is small and stays small; the two-level fan-out design guarantees that by construction, regardless of how large a single stream's viewership grows.

Concept → realization: what a connection server actually keeps in memory per stream

Zoom into one connection server's bookkeeping to make the “subscribed to exactly the streams it holds viewers for” claim concrete. For each stream it is currently serving any viewer for, it keeps a small registry entry:

python — one connection server's per-stream bookkeeping
stream_registry = {
    "knockdown-fight": {
        "subscription": <active broker subscription handle>,
        "sockets": {socket_1, socket_2, …}       # ~20,000 entries, this box's share
    },
    # ... one entry per other stream this box happens to hold any viewer for
}

The moment sockets for a given stream becomes empty — the last viewer of that stream on this particular box disconnected — the subscription handle is closed and the entry is dropped. A connection server holding viewers spread across many small streams simultaneously keeps many small entries; one holding all its viewers concentrated on tonight's single megastream keeps essentially one large entry. Both are correct outcomes of the same simple rule.

Why this chapter had to come before Chapter 6, not after

It might seem backward to design the broker layer before sizing the fleet it feeds — Chapter 5 before Chapter 6 — but the ordering is deliberate. The two-level fan-out argument only makes sense once the reader has a mental model of “many independent connection servers, each holding a slice of the viewers,” which Chapters 2 and 3 already established without naming an exact count. Chapter 6 needs a concrete pub/sub delivery mechanism to already exist before it can talk about session migration between connection servers, since migration is meaningless without a way for the new server a client lands on to immediately resubscribe and start receiving that stream's comments. Each chapter assumes exactly what the previous one built, in the order that makes each step provable rather than asserted.

A note on backpressure at the broker

One failure mode worth naming even though this lesson's numbers show it staying far from a real risk: what happens if one connection server falls behind its subscription, unable to drain messages as fast as the broker delivers them. Both Redis Pub/Sub and Kafka handle this differently — Redis simply keeps pushing and can disconnect a subscriber whose output buffer grows past a configured limit, treating a slow consumer as effectively equivalent to a dead one; Kafka's persisted log lets a slow consumer simply read more slowly, falling behind in its own committed offset without losing data, at the cost of a growing consumer lag metric. At 2,000 deliveries a second fleet-wide, no connection server in this lesson's design should ever approach either limit — the number is named here mainly so the choice of broker technology in the earlier comparison table can be read as a real tradeoff rather than an arbitrary preference.

What to watch once pub/sub is live

MetricWhat a problem looks like
Broker delivery rate vs. subscriber countshould track comment rate × connection-server count almost exactly; a mismatch means subscriptions are stale or duplicated
Consumer lag (Kafka) or dropped-subscriber count (Redis)rising lag means a connection server is falling behind its own local fan-out loop, not the broker itself
Topic count vs. active stream countshould match 1:1 under per-stream topic design; a mismatch usually means unsubscribe-on-last-viewer-leaves has a bug leaking stale topics
Publish latency (server R to broker ack)the first hop's contribution to end-to-end comment latency; should be single-digit milliseconds at this workload's throughput

A worked scenario at a smaller connection-tier size

Run the two-level fan-out arithmetic once more, this time for a smaller platform running the same comment feed at a more modest scale — 200,000 viewers, sized by Chapter 6's formula onto 10 connection servers, still at the 40/sec hype rate.

broker-level deliveries = 40/sec × 10 = 400 deliveries/sec
local fan-out per server = 200,000 ÷ 10 = 20,000 sockets, looped in-process

Four hundred broker deliveries a second instead of 2,000 — both numbers sit so far below any real broker's throughput ceiling that the difference is academic, but the exercise confirms the pattern holds at any scale this lesson considers: broker load scales with connection-server count, not with total viewers, because local fan-out is where viewer count actually gets absorbed. A platform could run this same architecture at 10× or 100× today's viewership and the broker tier would barely notice, provided the connection tier grows to match.

The same scenario, checked against Redis Pub/Sub's real published ceiling

It is worth checking the 400/sec and 2,000/sec figures against an actual measured number rather than just the phrase “comfortably handles well over a million operations a second.” Published benchmarks for Redis Pub/Sub on a single modest instance commonly show sustained throughput above 1,000,000 messages/sec for small payloads close to this lesson's 150-byte comment size. Express both worked scenarios as a fraction of that ceiling:

200,000-viewer scenario: 400 ÷ 1,000,000 = 0.04% of Redis's published ceiling
1,000,000-viewer scenario (this chapter's default): 2,000 ÷ 1,000,000 = 0.2% of Redis's published ceiling

Even the larger scenario uses a fraction of a percent of one broker instance's documented capacity — which is exactly the kind of sanity check worth running before trusting a “comfortably handles” claim in a design doc: not just believing the vendor's headline number, but converting this lesson's own workload into the same units and confirming the margin is real, not just plausible-sounding.

The number to carry into Chapter 6. Two thousand broker deliveries a second, fanning out locally into 40,000,000 total socket writes. The broker was never the hard part. The hard part is what each of those 50 boxes actually has to do, 20,000 times, forty times a second — and that is exactly where Chapter 6 goes next.
A connection server subscribes to a global "all-comments" topic instead of a per-stream topic. The platform runs 10,000 streams averaging 500 comments/sec combined, and this connection server only holds viewers for one 40/sec stream. What is the concrete cost of that design choice?

Chapter 6: The Stateful Connection Tier

Every earlier chapter has quoted a box count almost in passing — “2 boxes, by memory,” from Chapters 2 and 3; “50 connection servers,” borrowed forward in Chapter 5. This chapter is where that number gets derived properly, from three separate ceilings, and where the honest answer turns out to be nowhere near the optimistic memory-only estimate every earlier chapter flagged as incomplete.

Three ceilings, not one

A box holding WebSocket connections can run out of capacity in three structurally different ways, and each one needs its own arithmetic: it can run out of file descriptors (the OS runs out of numbers to hand out for open sockets), it can run out of memory (each held connection costs real RAM, as Chapters 2 and 3 computed), or it can run out of CPU (the work of actually writing a burst of messages out to every held socket costs real processor time). Whichever of the three hits its limit first is the one that actually decides fleet size — the other two are simply slack.

File descriptors: essentially not the constraint

Every open socket costs the OS one file descriptor, and the historical default limit — often 1,024 per process — is a relic of decades-old defaults, not a hardware ceiling. Production systems holding hundreds of thousands of connections routinely raise ulimit -n to 1,000,000 or higher; the kernel itself supports far more than any single process realistically needs at this lesson's scale. Name this ceiling, confirm it is tunable well past anything the other two ceilings will demand, and move on — it is not where this chapter's real number comes from.

Memory: the optimistic estimate, done properly

Chapter 3 settled on roughly 28KB per idle WebSocket connection. Budget 16GB of a box's RAM for connection state:

16,000,000 KB ÷ 28 KB ≈ 571,000 connections per box, by memory

By this number alone, 1,000,000 viewers would fit on 2 boxes. Every earlier chapter that quoted this figure flagged it as incomplete on purpose. Here is why.

CPU: the ceiling that actually binds

Memory measures how many sockets fit, sitting idle. It says nothing about the cost of a hype moment — the instant when 40 comments a second each need to be serialized and written out to every single socket a box is holding. Cost that write. Serializing a small message and issuing the socket write syscall costs roughly 10 microseconds of CPU time, a realistic figure for a well-optimized event loop doing a small, buffered write. A box holding N connections, during a burst of 40 messages a second, performs:

N × 40 writes/sec × 10μs/write = N × 400μs of CPU work per second

Take a real box — 16 cores — and budget half its capacity to fan-out work, leaving the other half for connection bookkeeping, heartbeats, and everything else running on the box:

16 cores × 0.5 = 8 core-seconds of CPU budget, per second

Solve for the largest N that fits inside that budget:

N × 400μs ≤ 8s ⇒ N ≤ (8) ÷ (0.0004) = 20,000 connections, by CPU

Twenty thousand. Not 571,000. The CPU ceiling is roughly 28× tighter than the memory ceiling, and it is the one that actually governs how many boxes this fleet needs.

CeilingConnections per boxBinding?
File descriptorseffectively unlimited (tunable)no
Memory (16GB ÷ 28KB)~571,000no
CPU (hype-moment fan-out writes)~20,000yes

The corrected fleet size

1,000,000 viewers ÷ 20,000 conns/box (CPU-bound) = 50 connection servers

Fifty boxes, not the 2 the memory estimate implied — the number Chapter 5 already borrowed forward. Check it against Chapter 0's bandwidth floor of 24 boxes, computed independently from network capacity alone: 50 exceeds 24, so CPU is the binding constraint end to end, and building a fleet sized only for bandwidth would still fall 26 boxes short once real fan-out CPU cost is counted.

Three ceilings, one fleet size — drag the real bottleneck

Drag per-write CPU cost and cores-per-box. Watch the CPU-bound connections-per-box bar move against the fixed 571,000-connection memory ceiling — and watch which one is actually smaller.

per-write cost (μs)10
cores per box16

The same ceiling, at two other box specs

The 16-core box was one choice among many real options. Redo the CPU-ceiling derivation for a smaller 8-core box and a larger 32-core box, holding the 50% fan-out budget and 10µs per-write cost fixed.

8-core: 8 × 0.5 = 4 core-seconds ⇒ N ≤ 4 ÷ 0.0004 = 10,000 connections/box
32-core: 32 × 0.5 = 16 core-seconds ⇒ N ≤ 16 ÷ 0.0004 = 40,000 connections/box

The connections-per-box ceiling scales exactly linearly with core count, as the formula predicts — doubling cores from 16 to 32 doubles the ceiling from 20,000 to 40,000. Translate both into fleet size for 1,000,000 viewers:

Box specConnections/box (CPU-bound)Fleet size
8-core10,000100 boxes
16-core (this chapter's working spec)20,00050 boxes
32-core40,00025 boxes

Twice the boxes at half the core count, half the boxes at twice the core count — the total CPU provisioned across the fleet stays essentially constant either way (100 × 8 = 800 cores, 50 × 16 = 800 cores, 25 × 32 = 800 cores). Choosing between many small boxes and few large ones is therefore not a capacity question at all under this formula; it is an operational one — blast radius per failure, bin-packing efficiency, and per-box fixed overhead, none of which this lesson's CPU-ceiling arithmetic decides on its own.

Sharding the fleet: consistent hashing, compressed

Fifty boxes need a way to decide which box a given viewer's connection lands on — and, crucially, a way that survives the fleet growing or shrinking without reshuffling everything. Naive hash mod N — route stream s to box hash(s) % N — looks reasonable until N changes: adding one box changes the modulus for every single key, and nearly every stream's assignment moves to a different box at once. Consistent hashing fixes this by mapping both boxes and keys onto the same ring (a fixed numeric space, positions found by hashing box IDs and stream IDs the same way) and assigning each key to the next box clockwise from it on the ring. Adding a 51st box only steals the keys that now fall between it and its clockwise neighbor — roughly one N-th of the ring — leaving everyone else's assignment untouched.

naive mod-N reshuffle: (N−1) ÷ N = 49 ÷ 50 = 98% of assignments move  vs.  consistent hashing: 1 ÷ 51 ≈ 2%

Ninety-eight percent of the fleet's connections reassigning at once, purely from adding one box, is its own reconnection storm — precisely the failure mode Chapter 7 studies. Consistent hashing turns fleet growth from a controlled 2% ripple into the default, safe behavior.

Session migration: what a viewer experiences when a box is drained

Scaling a box out of the fleet — a deploy, a graceful drain, or an unplanned crash — means every connection it was holding needs somewhere new to go. The ring already answers where: the same consistent-hash lookup that placed a stream's viewers on that box in the first place now points to whichever box is next in line. What the client actually experiences is a close frame (or, for a crash, simply silence past the missed-pong threshold from Chapter 3), followed by a reconnect to the box the ring now names, followed by that new box resubscribing to the stream's topic (Chapter 5) and, using the resume-token mechanism Chapter 7 builds, catching the client back up on anything it missed during the gap.

Concept → realization: sizing a box from first principles

python — the three-ceiling sizing calculation, as code
def connections_per_box(cores, cpu_budget_fraction, hype_rate, us_per_write,
                        ram_gb, kb_per_conn):
    cpu_ceiling = (cores * cpu_budget_fraction) / (hype_rate * us_per_write / 1e6)
    mem_ceiling = (ram_gb * 1e6) / kb_per_conn
    return min(cpu_ceiling, mem_ceiling), ("CPU" if cpu_ceiling < mem_ceiling else "memory")

n, bottleneck = connections_per_box(
    cores=16, cpu_budget_fraction=0.5, hype_rate=40,
    us_per_write=10, ram_gb=16, kb_per_conn=28)
# n ≈ 20,000 · bottleneck = "CPU"

n32, bottleneck32 = connections_per_box(
    cores=32, cpu_budget_fraction=0.5, hype_rate=40,
    us_per_write=10, ram_gb=16, kb_per_conn=28)
# n32 ≈ 40,000 · bottleneck32 = "CPU" — same function, doubled cores

The function returns whichever ceiling is smaller, and names it — a small piece of code that turns this entire chapter's arithmetic into something a capacity-planning script can run automatically every time hype rate, box size, or connection cost assumptions change, rather than re-deriving it by hand each time.

What to watch on the connection tier once it is live

MetricWhat a problem looks like
CPU utilization during a burst, per boxapproaching the 50%-of-16-cores budget means the next larger burst has no headroom
Connections per box vs. the 20,000 CPU ceilinga box significantly above 20,000 is one hype moment away from queueing writes instead of delivering them promptly
Ring rebalance eventsshould move ~1/N of connections on any single node add/remove; a much larger observed movement means the hashing implementation has a bug
Time from drain signal to fully re-subscribed elsewherethe real user-facing cost of scaling the fleet in or out
Key insight. The cheapest resource to check is rarely the one that binds. Memory looked like it settled this question in Chapter 2 — 2 boxes, done. CPU, checked properly, needed 25 times more. Whenever a capacity estimate comes from a single resource, ask which OTHER resource that estimate quietly assumed was not the bottleneck, and check it by hand before trusting the number.

Stress-testing the CPU ceiling against Chapter 0's viewership table

Chapter 0 stress-tested comment rate and viewer count independently. Run the fleet-size arithmetic across that same spread, holding the 20,000-connection CPU ceiling per box fixed:

ScenarioViewersComment rateCPU ceiling/boxBoxes needed
Quiet lull1,000,00010/sec80,00013
Tonight's peak1,000,00040/sec20,00050
Controversial call1,000,00080/sec10,000100
Once-a-decade event3,000,00040/sec20,000150

Two separate levers move fleet size in this table, and they move it differently: a higher comment rate shrinks each box's CPU ceiling (more writes per second, per socket), while more viewers simply needs more boxes at whatever ceiling the current rate allows. A capacity plan that only tracks viewer count and assumes a fixed comment rate will be caught flat-footed by the controversial-call row — the exact same 1,000,000 viewers, needing double the fleet, purely because everyone started typing at once.

How sensitive is the 50-box answer to the 50% CPU budget assumption

The 8-core-second budget came from reserving half of a 16-core box for fan-out work and half for everything else running on it. That 50% split was a judgment call, not a measured constant — check how far the fleet size moves if it is wrong:

CPU budget reserved for fan-outConnections/boxBoxes needed
25% (conservative, lots of other work on the box)10,000100
50% (this chapter's working assumption)20,00050
75% (aggressive, box does little else)30,00034

The fleet size swings by a factor of three across a range of defensible budget choices, which is exactly why this number belongs in a load test, not just a spreadsheet — Chapter 4 already made the case for measuring rather than trusting a single estimate, and this chapter's numbers are the ones most worth measuring for real, since they carry the widest uncertainty band of anything derived in this lesson.

A worked scale-out, start to finish

Put the pieces together as a timeline. Ten minutes before the main event, the fleet runs its baseline size for ordinary traffic — call it 15 boxes, sized for the quiet-lull row above. A monitoring alert fires as viewership climbs past 400,000 and the CPU-utilization metric from this chapter's watchlist crosses 40% on the existing fleet, well before it hits the 50% budget ceiling. Autoscaling adds boxes, and the consistent-hash ring absorbs each one with roughly a 1/N reshuffle — a small, controlled trickle of reconnects, nothing like a storm. By the time the knockdown happens and viewership peaks at 1,000,000, the fleet has grown to 50 boxes, each one comfortably inside its CPU budget, because the scale-out happened ahead of the peak instead of reacting to it.

Why the ring shards by stream, not by individual connection

One design detail deserves its own explanation: the consistent-hash ring in this chapter maps streams (or shards of a very large stream) to boxes, not individual viewer connections directly. Hashing each connection independently would scatter one stream's million viewers evenly across the entire fleet with no structure at all, which sounds harmless until Chapter 5's pub/sub subscriptions are considered: every one of those boxes would need to subscribe to that stream's topic, since some fraction of its viewers belong to it, and the “a connection server only subscribes to streams it actually holds viewers for” efficiency argument collapses back into something close to the wasteful global-topic case. Sharding by stream keeps a hot stream's viewers concentrated on a bounded, known set of boxes — the 50 this chapter derived — so the subscription set per box stays small and deliberate rather than accidentally spanning the whole fleet.

Splitting one megastream across many shards

A single stream with 1,000,000 viewers cannot live on one node’s 20,000-connection ceiling, so in practice the ring maps not “stream → one box” but “stream shard → one box,” with a stream popular enough to need it split into ceil(1,000,000 ÷ 20,000) = 50 virtual shards, each independently placed on the ring. A quiet stream with 200 viewers needs only one shard and lands on a single box; tonight’s fight needs all 50. The publish side does not need to know this split happened at all — Chapter 5's broker still publishes once per comment to the stream's topic, and every one of the 50 subscribed shards receives it identically, exactly as the two-level fan-out design intended.

Virtual nodes: making failure spread evenly

One refinement to the ring deserves a name before Chapter 7 leans on it. A plain consistent-hash ring with one point per physical box means a failed box's entire load lands on exactly one neighbor — a single successor absorbing all of it at once, which is its own small overload event. Production rings instead give each physical box many points on the ring — a hundred or more — called virtual nodes. When a physical box fails, its load was never concentrated at one ring position; it was already scattered across a hundred small arcs, each with a different neighbor, so the failure's load spreads across roughly that many different surviving boxes instead of one. This is the mechanism Chapter 7 assumes when it computes reconnecting clients “spread roughly evenly” across the surviving fleet — it is not automatic from consistent hashing alone, it specifically requires virtual nodes to be configured.

The number this chapter settles. Fifty connection servers, each holding roughly 20,000 viewers, bound by CPU rather than memory or file descriptors. That number, not the 2-box memory estimate, is what Chapter 5's broker load and Chapter 8's final assembly both build on. It is also the number that determines exactly how many viewers go dark at once if one box fails without warning — the question Chapter 7 answers next.
Memory alone suggests 571,000 WebSocket connections fit on one 16GB box. CPU, accounting for the cost of writing 40 messages/sec out to every held connection, caps it at about 20,000. Why does the CPU ceiling bind even though far more connections would physically fit in memory?

Chapter 7: Reconnection Storms

A deploy goes wrong. Five of the fleet's fifty connection servers — 100,000 held viewer connections between them — go down within the same few seconds. Every one of those 100,000 browser tabs notices almost immediately: the WebSocket closes, or several heartbeat pongs go unanswered past Chapter 3's threshold. Every one of those 100,000 clients now tries to reconnect. This chapter is about what happens next, and why the naive version of “just try again” turns one bad deploy into an outage far larger than the original five boxes.

Where the survivors go

Chapter 6's consistent-hash ring, built with virtual nodes specifically so a single failure does not concentrate all its traffic on one unlucky successor, spreads those 100,000 reconnecting clients roughly evenly across the fleet's 45 surviving boxes:

100,000 ÷ 45 ≈ 2,222 reconnecting clients, per surviving box

Twenty-two hundred is a perfectly survivable number of NEW connections for one box to accept — spread out over even a few seconds. The entire problem is that naive clients do not spread them out.

The naive retry: everyone arrives at once

The obvious first implementation: on disconnect, wait exactly 1 second, then retry. Every one of the 100,000 clients disconnected within roughly the same instant, so every one of them retries within roughly the same instant too — in practice, network jitter spreads the actual attempts across perhaps a 50-millisecond window, not a perfect single tick, but that is a far narrower window than the capacity built to absorb it. A box can process new TLS handshakes at a rate limited by real CPU crypto cost — call it 2,000 handshakes/sec, a working figure for a box also busy with Chapter 6's steady-state fan-out work. Compute the offered rate during that 50ms window:

2,222 ÷ 0.05s ≈ 44,440 handshake attempts/sec, offered
44,440 ÷ 2,000 = 22× over the box's real handshake capacity

Twenty-two times over capacity, on every one of the 45 surviving boxes, simultaneously. Most of those handshake attempts time out or get refused — and a client whose naive retry logic waits exactly 1 second and tries again lands in exactly the same synchronized wave one second later. The storm does not damp on its own. It repeats, indefinitely, for as long as every client keeps the same fixed delay.

Why a “random-looking” coarse delay does not fix it

A tempting half-measure: instead of a fixed 1 second, pick a delay uniformly from a small set, say {1, 2, 3} seconds. That spreads clients across three separate waves instead of one — but each wave still concentrates roughly a third of the reconnecting population, 2,222 ÷ 3 ≈ 741 per box per wave, arriving within the same narrow window as before. The problem was never the number of discrete choices; it was the granularity. Three buckets divided by 2,222 clients per box still averages 741 clients landing in the same fraction-of-a-second window per bucket — still roughly 741 ÷ 0.05 ÷ 2,000 ≈ 7.4× over capacity. Coarse jitter shrinks the storm; it does not remove it.

Fixed retry vs. jittered retry — offered load against handshake capacity

Drag the number of reconnecting clients per box and the jitter window width. Watch offered handshake rate fall as the window widens, against the fixed 2,000/sec capacity line.

clients reconnecting / box2,222
jitter window (sec)0.05

Full jitter: the fix, and why the numbers actually work

The fix that works is full exponential backoff with jitter: on attempt number a (starting at 0), pick the delay uniformly at random from [0, min(cap, base × 2^a)], with a base of 1 second and a cap of 30 seconds — the same formula AWS documents for its own SDKs. On the very first retry attempt, a=0, so the window is [0, min(30, 1)] = [0, 1s]. Recompute the offered rate with clients spread uniformly across a full 1-second window instead of a 50-millisecond one:

2,222 ÷ 1s = 2,222 handshake attempts/sec, offered
2,222 ÷ 2,000 ≈ 1.11× over capacity (versus 22 × for fixed-delay)

Just widening the retry window from 50 milliseconds to 1 second — nothing more — drops the overload factor from 22× to about 1.1×. The vast majority of that first jittered wave succeeds; only a small sliver, the unlucky fraction that happened to land in the same split-second as too many others, needs a second attempt at all.

The stragglers: what happens on attempt two

For the small fraction still unconnected, attempt a=1 uses window [0, min(30, 2)] = [0, 2s] — twice as wide, spreading an already-small remaining population even further. By attempt a=3, the window is [0, min(30, 8)] = [0, 8s], comfortably wide enough that essentially every remaining client succeeds well before the 30-second cap is ever reached. The pattern is deliberate: each failed attempt widens the net specifically because a failure is itself evidence the server is under more contention than expected, and backing off further is the correct response to that evidence — not a fixed, uninformed guess repeated forever.

Sizing the replay buffer against the worst realistic wait

Chapter 2 sized its SSE ring buffer — 500 events — against a 12.5-second reconnect assumption for an ordinary network blip. A storm is a harsher case: take attempt a=3's 8-second window as the worst plausible wait for the slowest surviving stragglers, and check it against the same buffer, at the 40/sec hype rate:

8 sec × 40/sec = 320 events, worst case < 500-event buffer

The same 500-event buffer Chapter 2 sized for an ordinary blip already covers a genuine reconnection storm's worst-case straggler, with margin — a pleasant consequence of picking that number with some headroom the first time, rather than sizing it to the bare minimum.

Concept → realization: the reconnect loop, written correctly

javascript — full jitter exponential backoff, from the AWS formula
async function reconnectWithJitter(streamId, lastEventId) {
  let attempt = 0;
  const base = 1000, cap = 30000;   // milliseconds
  while (true) {
    try {
      const ws = await connect(streamId, lastEventId);  // resume token — Ch. 2's mechanism, reused
      return ws;                                     // success — reset attempt count elsewhere
    } catch (e) {
      const window = Math.min(cap, base * Math.pow(2, attempt));
      const delay = Math.random() * window;      // full jitter — uniform over [0, window]
      await sleep(delay);
      attempt++;
    }
  }
}

Notice lastEventId traveling into connect on every attempt, success or not — the same resume-token mechanism Chapter 2 built for SSE's Last-Event-ID, reused here for WebSockets even though the WebSocket protocol itself has no native equivalent. The server-side handler on a successful reconnect replays the ring buffer since that id, exactly as Chapter 2 described, closing the gap this chapter's backoff delay opened.

What to watch during a real incident

MetricWhat it tells you mid-incident
Handshake attempt rate, per surviving boxshould stay near the jittered ~1.1× figure, not spike toward the 22× fixed-delay figure
Fraction of clients still on attempt 0 vs. later attemptsa growing tail on attempt 2+ means the fleet has not yet absorbed the initial wave
Replay-buffer miss rateclients presenting an id older than the buffer holds means some viewers experienced a true, unrecoverable gap — worth paging on, not just logging
Time from incident start to reconnect rate returning to baselinethe actual incident-duration number to report, since it is what viewers experienced
Key insight. Jitter does not remove the need for spare capacity — the 45 surviving boxes still had to absorb 100,000 reconnecting clients somehow. What jitter does is convert an instantaneous, impossible 22× spike into a sustained, survivable ~1.1× one, by trading a narrow time window for a wide one. The total work is identical either way; only its shape in time changes, and that shape is the entire difference between an outage and a blip nobody files a ticket about.

Three jitter strategies, compared

“Full jitter” is one specific formula among a small family, and the differences are worth naming since they trade off offered load against average delay differently:

StrategyFormulaTradeoff
No jitterdelay = base × 2^attemptmaximally synchronized — the storm case above
Equal jitterdelay = half + random(0, half), where half = window/2guarantees a minimum delay (never fully collapses to zero) at the cost of a narrower spread than full jitter
Full jitterdelay = random(0, window)widest possible spread, lowest peak offered rate — this chapter's choice
Decorrelated jitterdelay = random(base, prev_delay × 3)grows the window based on the client's own history rather than a fixed formula, spreading a population that retries repeatedly even further over time

Full jitter is the right default for this lesson's case specifically because the goal is minimizing peak offered load on the very first retry wave, which is exactly when the 45 surviving boxes are most fragile — freshly absorbing 100,000 clients on top of their own steady-state Chapter 6 fan-out work, with zero spare capacity built up yet.

Equal jitter, worked through the same numbers

Full jitter picked delay = random(0, window), spreading attempts uniformly across the whole window, including near zero. Equal jitter instead guarantees a minimum wait of half the window — delay = window/2 + random(0, window/2) — trading away full jitter's very-fast retries in exchange for never letting the window collapse toward zero. Run it through this chapter's numbers: attempt a=0, window [0, 1s], so equal jitter draws uniformly from [0.5s, 1s] instead of [0, 1s].

spread window = 1s − 0.5s = 0.5s (versus full jitter's full 1s spread)
2,222 ÷ 0.5s = 4,444 handshake attempts/sec, offered
4,444 ÷ 2,000 = 2.22× over capacity (versus full jitter's 1.11×)

Equal jitter's overload factor comes out exactly double full jitter's, because it concentrates the same 2,222 clients into half the time window — the arithmetic is a direct consequence of narrowing the spread, not a different phenomenon. Equal jitter still crushes the no-jitter 22× figure, and its guaranteed minimum delay is a real advantage when a system also needs to bound the best case latency, not just tame the worst case — full jitter is chosen here specifically because this chapter's priority is minimizing peak offered load, not bounding best-case latency.

The server's other lever: shedding load on purpose

Jitter is a client-side fix, and it assumes every client is well-behaved. A server-side backstop is worth having regardless: when a box's incoming-handshake rate crosses some threshold — say, 80% of the 2,000/sec capacity figure — it can start returning an explicit 503 Service Unavailable with a Retry-After header to new connection attempts, rather than accepting them into a queue that will only time out anyway. This is a direct application of Chapter 0's fastest-real-fix instinct from its twin lesson, Scaling Reads: when a resource is genuinely over capacity, the honest move is to reject the excess deliberately, quickly, and cheaply, rather than let it silently queue and degrade everything running behind it. A client receiving an explicit 503 with a retry hint backs off correctly even without sophisticated jitter logic of its own; a client whose connection attempt simply times out has no such signal and is far more likely to retry immediately in a way that makes the problem worse.

Where the 80% load-shedding threshold actually comes from

The 80% figure above should not be asserted — derive it from the same capacity number this chapter has used throughout. A box's real handshake ceiling is 2,000/sec; shedding load exactly at 2,000/sec leaves zero margin for the handshakes already queued or in flight when the threshold trips, guaranteeing some of them time out anyway. Reserve a safety margin instead — say, the same 20% headroom this lesson's fleet sizing elsewhere treats as reasonable slack — and shed at:

2,000/sec × (1 − 0.20) = 1,600/sec, or 80% of the 2,000/sec ceiling

That 80% is not an arbitrary round number picked for tidiness; it is the same capacity figure this chapter derived for handshake cost, discounted by a margin large enough to absorb the requests already in flight at the instant the threshold trips. A tighter margin (shedding at 95%) reclaims more useful capacity but risks the box still tipping over before the 503 responses take effect; a looser one (shedding at 60%) wastes real capacity that the jittered retry wave, at ~1.1× overload, would have absorbed without any shedding at all.

How the storm's size changes the picture

Not every failure drops five boxes. Recompute the fixed-delay overload factor for a smaller, single-box failure and a larger, ten-box failure, holding the 45-versus-40-surviving-box spread and 2,000/sec capacity fixed:

Boxes lostConnections droppedPer surviving boxFixed-delay overloadJittered (1s window) overload
120,0004084.1×0.20× (well absorbed)
5 (this chapter's scenario)100,0002,22222×1.11×
10 (a much worse event — a whole availability zone)200,0005,00050×2.5×

Even jitter alone stops being fully sufficient at the ten-box scenario — 2.5× is a real, sustained overload, not the near-perfect absorption of the five-box case. That is precisely the scenario the server-side load-shedding backstop above exists for: jitter handles the common case gracefully on its own, and 503-with-backoff catches the tail where even a well-spread retry wave still exceeds capacity.

Subscription churn: the broker side of a storm

One more cost this chapter has not yet named: when a box dies, its Chapter 5 broker subscriptions die with it, and every replacement box that picks up its reconnecting clients must issue fresh subscribe calls for whichever streams those clients belong to. At 100,000 dropped connections concentrated on one megastream, that is a small, one-time burst of subscribe operations — at most a few dozen, since only the new destination boxes that did not already hold this stream's shard need to subscribe at all — utterly trivial next to the 2,000 broker deliveries/sec this stream generates in steady state. Named here mainly to close the loop: nothing about a storm meaningfully stresses the broker tier from Chapter 5; the entire cost lives in the connection tier's handshake capacity, which is exactly where this chapter has spent its arithmetic.

What the viewer actually experiences, start to finish

Put every piece of this chapter into one timeline, from a single viewer's perspective. Their WebSocket closes without warning at t=0. Their client waits a random delay somewhere in [0, 1s] — say it draws 0.4 seconds — then attempts to reconnect, presenting its last-seen event id. The consistent-hash ring, aware the old box is gone, routes the handshake to a new box. If that box is under the 1.1× overload this chapter computed, the handshake very likely succeeds on the first try. The new box replays anything published to this stream's topic since the presented id — at most a few hundred events, well inside the 500- event buffer — and the viewer's comment feed resumes, having missed perhaps half a second of real time, with every comment posted during that gap still delivered, just slightly late instead of lost.

That is the entire point of stacking Chapters 2, 5, 6, and 7 on top of each other the way this lesson has: no single mechanism, on its own, makes a box failure invisible. The resume-token buffer alone does nothing without a working reconnect path; jittered backoff alone does nothing without somewhere correct to reconnect to; the consistent-hash ring alone does nothing without a buffer to replay from once the client arrives. It is the combination, each piece covering exactly the gap the others leave open, that turns five dead boxes into a half-second hiccup instead of an outage.

A note on testing storms before they happen for real

None of this chapter's numbers need to wait for a real incident to be validated. A controlled chaos-engineering exercise — deliberately killing a known number of connection servers during a scheduled, low-stakes window — measures the actual offered handshake rate, the actual recovery time, and the actual replay-buffer hit rate directly, against the theoretical figures this chapter derived. A team that has only ever seen the 1.1× jittered-overload number on paper is in a meaningfully weaker position during a real 3 AM incident than a team that has watched it happen on purpose, on a Tuesday afternoon, with everyone watching the dashboards this lesson's earlier chapters named. The formula tells you what should happen; deliberately causing the failure once is what confirms it actually does.

The number to carry into Chapter 8. A 5-box failure, handled with full jitter, costs the surviving fleet about 1.1× its steady-state handshake capacity for roughly a second, and costs an affected viewer under a second of resumed-not-lost comments. Chapter 8 assembles every number from every chapter — fan-out, protocol choice, pub/sub, connection-tier sizing, and this chapter's storm math — into one live, adjustable picture of the whole system.
A bad deploy drops 100,000 connections, spread across 45 surviving boxes at ~2,222 clients each. Fixed 1-second retry produces a 22× handshake overload per box. Full jitter's first attempt uses a window of [0, 1s]. Why does that specific change bring the overload down to about 1.1×, rather than some other factor?

Chapter 8: Assembling the Realtime Path

Every earlier chapter derived one piece of this system in isolation. This chapter puts all of them back together and follows one comment through the whole path, with every number from every chapter attached to the hop it belongs to — then makes the entire system adjustable, so the arithmetic can be checked at any viewer count, not just tonight's.

One comment, the full path, every number named

0 · post
a viewer's browser sends the comment over their already-open WebSocket (Ch. 3–4) · full duplex means no separate POST needed
1 · write
a stateless API server saves it to the database · an ordinary write, nothing in this lesson changes it
2 · publish
that server publishes to stream:<id>:comments (Ch. 5) · ~2,000 broker deliveries/sec fleet-wide at tonight's rate
3 · local fan-out
each of 50 connection servers (Ch. 6, CPU-bound at ~20,000 conns/box) loops its held sockets, in-process
4 · deliver
1,000,000 browsers receive the 150-byte payload over their held WebSocket · ~48 Gbps fleet-wide at hype rate (Ch. 0)
(if a box fails)
the consistent-hash ring re-routes its viewers (Ch. 6) · jittered reconnect absorbs the wave at ~1.1× capacity (Ch. 7) · the resume buffer replays anything missed

Five steps and one conditional branch, and every single number attached to them was derived by hand somewhere in Chapters 0 through 7. Nothing in this assembly is new arithmetic — it is the same numbers, finally shown as one connected system instead of eight separate exercises.

The whole system, live — drag viewer count and comment rate

Recomputes, from Chapters 0 and 6's formulas: total deliveries/sec, egress bandwidth, and connection-server count (CPU-bound). Watch which number moves with which slider — and which one barely moves at all.

viewers1,000,000
comment rate (/sec)40
boxes failed (storm test)0

Drag the “boxes failed” slider to zero and back up while watching the readout below the diagram. The steady-state numbers — deliveries, bandwidth, server count — do not move at all, because a transient failure and its jittered recovery are, by design, invisible to the system's long-run capacity math. Only the reconnect-overload readout reacts, and only for the roughly one second Chapter 7 derived it takes to absorb. That invariance is not an accident of this widget; it is the entire point of building the resilience layer the way Chapters 6 and 7 built it — a well-designed failure response should be a small, temporary perturbation on top of a system, not a change to the system's fundamental sizing.

What moves with which slider, and why that matters

Play with the widget above and a pattern emerges that is worth stating explicitly, because it is the real lesson of assembling the system rather than studying its pieces separately. Connection-server count is driven almost entirely by viewer count, through Chapter 6's fixed 20,000-connections-per-box CPU ceiling — it barely reacts to comment rate until the rate climbs enough to shrink that per-box ceiling meaningfully. Egress bandwidth is driven by both factors equally, since it is their product. And the “boxes failed” slider changes nothing about the steady-state numbers at all — its entire effect is on the transient reconnect-overload figure from Chapter 7, which recovers within about a second and leaves no lasting mark on the system's capacity.

A second worked walk-through, at a non-default setting

Drag the widget to 2,000,000 viewers and 80 comments a second — a genuinely different scenario from the 1,000,000/40 default, not just a bigger version of it — and work through what each formula returns by hand, the same way the default scenario was derived across Chapters 0 through 7.

deliveries/sec = 80 × 2,000,000 = 160,000,000
egress bandwidth = 160,000,000 × 150 bytes × 8 = 192,000,000,000 bits/sec ≈ 192 Gbps

Connection-server count follows Chapter 6's ceiling, but that ceiling itself is not fixed — it shrinks as comment rate rises, per the per-write CPU cost formula. At 80/sec instead of 40/sec, each write still costs 10µs, so the per-box ceiling halves before viewer count is even applied:

N ≤ 8s ÷ (80 × 10µs) = 8 ÷ 0.0008 = 10,000 connections/box (CPU-bound, at 80/sec)
2,000,000 ÷ 10,000 = 200 connection servers

Compare against the default scenario: viewers doubled (1M→2M) and rate doubled (40→80/sec), and connection-server count went up (50→200) — not 2× — because it responds to both factors at once, through two separate mechanisms: more viewers directly needs more boxes, and a higher rate simultaneously shrinks how many connections each box can hold. Egress bandwidth also went up 4× (48 Gbps→192 Gbps), for the more obvious reason that it is a direct product of both factors. The widget makes this visible instantly by dragging two sliders at once; working it out by hand here is what confirms the widget is not just producing a plausible-looking number.

Quantity1,000,000v / 40/sec (default)2,000,000v / 80/secRatio
Deliveries/sec40,000,000160,000,000
Egress bandwidth48 Gbps192 Gbps
Connection servers50200

The full numbers, at three scales

ScenarioViewersRateDeliveries/secBandwidthConnection servers
Quiet lull1,000,00010/sec10,000,00012 Gbps13
Tonight's peak (this lesson's running example)1,000,00040/sec40,000,00048 Gbps50
Once-a-decade event3,000,00040/sec120,000,000144 Gbps150

Every column in that table traces back to a formula this lesson derived by hand: deliveries from Chapter 0's rate × viewers, bandwidth from multiplying by Chapter 0's 150-byte payload, and connection servers from Chapter 6's viewers ÷ 20,000. A system this size is not mysterious once each piece has been priced — it is eight small, checkable multiplications, chained together in the order Chapters 0 through 7 built them.

What this lesson deliberately left out

Three real questions sit just outside this lesson's scope, worth naming rather than pretending they do not exist. First, exactly-once delivery: this system guarantees a comment is eventually delivered to every connected viewer, with the resume buffer covering brief gaps, but a client that reconnects mid-replay could, in rare edge cases, see the same comment rendered twice if the client-side deduplication (matching on the comment's id field from Chapter 0's wire format) is not implemented carefully. Second, cross-region delivery: this lesson sized one fleet in one region; a global audience needs regional connection-tier fleets and a regional broker topology, which is a real extension of Chapter 5's pattern but not one this lesson builds. Third, moderation and abuse: nothing here filters what gets published, and a real comment feed needs a moderation step between write and publish that this lesson's architecture diagram has a natural slot for (between Chapter 5's steps 1 and 2) but does not design.

Comparing this architecture against the road not taken

DesignWhat it gets rightWhere it breaks at 1,000,000 viewers
Short polling onlysimplest possible implementation500,000 QPS of mostly-empty requests, Chapter 1
SSE, no pub/sub, direct fan-out from the write serverreal push, no polling taxthe write server has no socket to any viewer, Chapter 5
WebSockets, sized by memory alonefull duplex, looks cheap (2 boxes)CPU-bound in practice at 25× more connections than it can serve, Chapter 6
This lesson's designWebSockets, pub/sub-decoupled, CPU-sized, jittered reconnecthandles a 10-box simultaneous failure at a survivable ~2.5× overload, Chapter 7
The whole lesson, in one line. A fan-out of a million was never optional — every chapter just decided where that multiplication happens: on request volume (Ch. 1, and it loses), on held-open connections (Ch. 2–3), on a two-level broker split (Ch. 5), on CPU rather than memory (Ch. 6), and on a jittered retry window rather than a synchronized one (Ch. 7). Nothing here made 40,000,000 deliveries a second cheap. It made the cost land in places that scale.

Concept → realization: the whole path, end to end, in one place

Every code fragment from Chapters 2, 3, and 5 belongs together as one system. Here is the shape of all of them, composed:

python — the assembled system, every chapter's piece in its place
# --- stateless API tier (any box, any count) ---
async def post_comment(request, stream_id):
    comment = await save_to_db(request, stream_id)          # Ch. 8, step 1
    await broker.publish(topic_for(stream_id), comment.to_wire())  # Ch. 5, step 2
    return {"status": "ok"}

# --- connection tier (50 boxes, Ch. 6, placed by the ring) ---
async def comment_socket(ws, stream_id, last_event_id=None):
    await ws.accept()
    if last_event_id:                                        # Ch. 7's resume path
        for ev in ring_buffer[stream_id].since(last_event_id):
            await ws.send(ev.to_frame())
    subscription = connection_registry.add(stream_id, ws)      # Ch. 5's local registry
    heartbeat_task = asyncio.create_task(send_pings(ws, interval=25))  # Ch. 3
    try:
        while True:
            done, _ = await asyncio.wait(
                [ws.recv(), subscription.next_event()], return_when=asyncio.FIRST_COMPLETED)
            for task in done:
                result = task.result()
                if result.is client_message:
                    await presence_bus.publish(stream_id, result)     # typing indicators, Ch. 4
                else:
                    await ws.send(result.to_frame())                 # Ch. 6's local fan-out, per socket
    finally:
        heartbeat_task.cancel()
        connection_registry.remove(subscription)

Nothing in this composed listing is new. Every line traces to a chapter, and the file is short enough to read in one sitting precisely because each chapter did the hard work of deciding what belongs at each step before any of this code was written — the code is almost an afterthought once the numbers behind it are right.

Reading a real incident through this lesson's metrics

Put the whole monitoring picture together as a single incident narrative, using exactly the metrics named across Chapters 0 through 7. At 8:47 PM, publish-to-deliver p99 latency (Chapter 0's first metric) begins climbing from 40ms toward 400ms. Per-box CPU utilization (Chapter 6) is already at 55%, over its 50% budget — the fight has entered a hype stretch the fleet was not pre-scaled for. Autoscaling adds boxes; the consistent-hash ring absorbs them at a controlled ~2% ripple (Chapter 6) rather than a storm, and by 8:49 PM utilization is back under budget and latency has recovered. No page fired, because the team was watching CPU utilization crossing 50%, not waiting for user complaints about slow comments — the entire value of deriving these numbers by hand in advance is knowing exactly which dashboard line to watch before the incident, rather than reverse-engineering it during one.

Sensitivity check: what if the per-write CPU cost assumption is wrong

Chapter 6's 10-microsecond per-write figure was an estimate, not a measurement from this specific system. Check the final fleet-size number's sensitivity to it, holding everything else fixed at tonight's 1,000,000 viewers, 40/sec:

Per-write CPU costConnections/box (CPU-bound)Connection servers needed
5µs (a leaner, more optimized write path)40,00025
10µs (this lesson's working assumption)20,00050
20µs (a less optimized serialization path)10,000100

A factor-of-two error in one micro-benchmark assumption produces a factor-of-two error in fleet size. That is not a flaw in this lesson's method — it is the honest reason Chapter 4's load-testing recommendation matters more than any single number in this lesson: every formula here is only as good as its measured inputs, and the formulas themselves are what make a wrong input easy to find and correct, rather than buried in a system nobody can explain.

Where each piece goes next

Piece of this lessonWhere it connects in the broader catalog
The read-capacity wall (Ch. 0's twin problem)Scaling Reads — the same M/M/1 queueing math, applied to a database instead of a connection tier
Sharding a hot key across many boxes (Ch. 6)Scaling Writes — the same consistent-hashing ring, built from scratch, applied to write partitioning
Retry storms and load shedding (Ch. 7)the wider pattern of backpressure and graceful degradation under overload, wherever a system meets a traffic spike it wasn't sized for
Message brokers and topic design (Ch. 5)event-driven architectures generally — the same publish/subscribe shape reappears anywhere a write needs to reach consumers it cannot see directly

The footer's worth of numbers, memorized

If only a handful of this lesson's numbers survive in memory a month from now, these are the ones worth keeping, because every other derivation in the lesson builds on them: a fan-out factor is a product of rate and audience, and both factors matter equally (Chapter 0); holding a connection open is cheaper in aggregate than re-asking a question whose answer is almost always still “nothing new” (Chapters 1–2); full duplex costs roughly 40% more memory per connection than one-way, and earns that cost only when the client genuinely needs to talk back (Chapters 3–4); the server that writes and the servers that hold sockets should never be the same fleet (Chapter 5); memory tells you what fits, CPU tells you what survives a burst, and they can disagree by more than an order of magnitude (Chapter 6); and jitter does not shrink the total work a failure creates, it only reshapes it in time from an instant into a window a system can actually absorb (Chapter 7).

The one question worth asking about any realtime system, not just this one

Strip away the comment-feed specifics and one question remains, applicable to any system fanning out from one write to many reads: where does the multiplication happen, and in what currency does that location pay for it? A notification system fanning out to push tokens, a multiplayer game state syncing across players, a collaborative document propagating edits — every one of them has its own version of Chapter 0's fan-out multiplier, and every one of them answers the same question this lesson answered for comments: request volume, held connections, memory, or CPU, chosen deliberately rather than defaulted into. The specific numbers in this lesson — 40 comments a second, 1,000,000 viewers, 20,000 connections per box — belong to one scenario. The method of pricing each currency honestly, by hand, before committing to an architecture belongs to all of them.

Closing thought. Every chapter in this lesson started from the same fixed fact: one comment, during a hype moment, has to reach a million people who never asked for it. Nothing built here made that fact smaller. What changed, chapter by chapter, was who pays for it, in what currency — request volume, held connections, memory, CPU, or a few hundred milliseconds of jittered delay — and paying the right currency in the right place, priced honestly by hand, is the entire difference between a system that melts under its own popularity and one that barely notices.

A last stress test: everything at once

Combine the worst honest case from every chapter into one scenario, to see whether the design holds together under compounding stress rather than one variable at a time. The once-a-decade event from Chapter 0 (3,000,000 viewers), during its own controversial-call spike (80 comments/sec, from Chapter 6's stress table), fifteen minutes after a ten-box failure the fleet has not fully recovered from (Chapter 7's worst-case row):

QuantityValueFormula
Deliveries/sec240,000,00080 × 3,000,000
Egress bandwidth288 Gbps240,000,000 × 150 bytes × 8
Connections/box (CPU-bound at this rate)10,000Chapter 6's ceiling, halved at 2× the baseline rate
Connection servers needed3003,000,000 ÷ 10,000
Reconnect overload, 10 boxes down mid-event~2.5×, jitteredChapter 7's worst-case row, same ratio regardless of fleet size

Every number in that table is still just the same handful of formulas, evaluated at a harsher input — nothing new had to be invented to answer “what if everything bad happens at once.” That, more than any individual chapter, is the real test of whether a system was designed from real arithmetic or from vibes: arithmetic composes cleanly under compounding stress; vibes do not.

Beyond this lesson

Two directions extend naturally past where this lesson stops. Multi-region delivery repeats Chapter 5's broker pattern once per region, plus a cross-region replication layer between the regional brokers, so a comment posted to a viewer served out of one region still reaches a viewer served out of another with the same resume-token guarantees this lesson already built. And a truly global platform revisits Chapter 6's per-box ceilings against very different hardware — a box optimized for connection count over raw compute changes every number in this lesson's central table, without changing a single one of the formulas that produced it. The formulas are the durable part; the constants they were evaluated at are always worth re-measuring against whatever hardware and traffic a real deployment actually has.

Using the widget's sliders as a mental model: viewer count rises from 1,000,000 to 2,000,000 while comment rate stays at 40/sec. What happens to connection-server count and egress bandwidth, and why do they not move by the same explanation?