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.
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.
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:
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.
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:
Convert to the unit a network engineer actually budgets in — bits per second, since that is how network capacity is rated:
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.
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.
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.
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.
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 rate | Deliveries/sec | Egress bandwidth | Boxes (bandwidth floor) |
|---|---|---|---|
| 10/sec — a quiet mid-match lull | 10,000,000 | 12 Gbps | 6 |
| 20/sec | 20,000,000 | 24 Gbps | 12 |
| 40/sec — tonight’s knockdown | 40,000,000 | 48 Gbps | 24 |
| 80/sec — a controversial call, chat exploding | 80,000,000 | 96 Gbps | 48 |
| 160/sec — the final bell, everyone typing at once | 160,000,000 | 192 Gbps | 96 |
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.
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.
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.
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:
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 fraction | Viewers | Deliveries/sec | Egress bandwidth |
|---|---|---|---|
| 1.5% (a modest fight) | 600,000 | 24,000,000 | 28.8 Gbps |
| 2.5% (tonight, measured) | 1,000,000 | 40,000,000 | 48 Gbps |
| 4.0% (a title unification bout) | 1,600,000 | 64,000,000 | 76.8 Gbps |
| 7.5% (a once-a-decade global event) | 3,000,000 | 120,000,000 | 144 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.
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.
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:
| Chapter | Attacks fan-out by |
|---|---|
| 1 · Polling | trying to answer with a request/response tax — and showing exactly why that tax is unaffordable |
| 2 · Server-Sent Events | a real one-way push channel, held open instead of re-requested |
| 3 · WebSockets | full-duplex push, at the cost of sticky, stateful infrastructure |
| 4 · Choosing a Protocol | putting the numbers from Chapters 1–3 side by side instead of guessing |
| 5 · Pub/Sub Decoupling | separating the server that receives a comment from the servers that hold sockets to a million viewers |
| 6 · The Stateful Connection Tier | finding the real per-box ceiling — and it is not the 24-box bandwidth floor computed above |
| 7 · Reconnection Storms | surviving the moment a slice of that fleet dies at once |
| 8 · Assembling the Realtime Path | the whole system, live, for any viewer count |
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:
| Reason | Concretely |
|---|---|
| Bandwidth is not the tightest constraint | Chapter 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 it | Unlike 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 moves | Tonight’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.
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:
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.
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:
| Metric | What it is | Why it matters here |
|---|---|---|
| Publish-to-deliver latency, p50 and p99 | time from a comment hitting the broker to it leaving a connection server's socket | this is the number viewers actually feel; it lags every other metric, so watch it directly rather than inferring it |
| Per-box egress bandwidth | bytes/sec actually leaving each connection server's NIC | approaching the realistic 2 Gbps/box ceiling from this chapter, well before any 10 or 25 Gbps spec-sheet number |
| Broker consumer lag | how far behind a connection server's subscription is from the broker's latest published message | a growing lag on even one connection server means its viewers are silently falling behind everyone else's |
| Held-connection count per box | live socket count on each connection server | directly determines how many viewers go dark if that one box fails — the number Chapter 7's storm math depends on |
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.
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?
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:
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 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.
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:
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.
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?
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:
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.
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:
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.
| Quantity | Value |
|---|---|
| Poll interval | 2 sec |
| Total QPS (activity-independent) | 500,000 |
| Quiet-stretch comment rate | 0.1/sec |
| Fraction of polls empty | ~82% |
| Empty QPS | ~410,000 |
| Bandwidth spent confirming nothing | ~2.6 Gbps |
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.
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:
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 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:
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:
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.
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:
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.
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.
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.
| Property | Short polling (2s) | Long polling (25s cap) |
|---|---|---|
| Requests/sec, quiet stretch | 500,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 sec | near-immediate once posted |
| Infra needed beyond plain HTTP | none | servers 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.
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.
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.
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:
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.
| Metric | Why it matters for polling specifically |
|---|---|
| Empty-response rate | the 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 clients | QPS 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-comment | the actual user-facing latency; for short polling this should hover near half the interval, for long polling near the network round trip |
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 trips | Overhead bytes | Overhead 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.
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.
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:
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 rate | P(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 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.
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.
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):
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.
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.
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.
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:
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.
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.
| Property | SSE |
|---|---|
| Direction | server → client only |
| Transport | plain HTTP, no protocol upgrade |
| Reconnect | automatic, built into the browser, with Last-Event-ID resume |
| Payload type | text (UTF-8) only |
| Client library needed | none — native EventSource |
| Per-connection cost | ~20KB memory, held for the connection's lifetime |
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.
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.
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.
| Property | Long 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 handling | none built in — must be hand-rolled | Last-Event-ID, standardized |
| Works through restrictive proxies | usually, since it looks like ordinary HTTP | usually, 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.
| Metric | What a problem looks like |
|---|---|
| Connections per box vs. the 800,000 memory ceiling | approaching it means the next viewer spike causes OOM kills, not graceful degradation |
| Ring buffer replay rate | a 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 connections | should be near-instant; a rising number means the connect-time handshake path (not the steady-state stream) is under load |
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 delay | Events missed | Buffer needed | Covered by the 500-event buffer? |
|---|---|---|---|
| 1 sec (fast, cached TLS) | 40 | 40 | yes, with huge margin |
| 12.5 sec (this chapter's design point) | 500 | 500 | yes, exactly |
| 30 sec (cold DNS + full TLS handshake) | 1,200 | 1,200 | no — 700 events silently lost |
| 90 sec (a real outage, not a blip) | 3,600 | 3,600 | no — 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.
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.
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:
| Approach | Steady-state bandwidth | vs. full-page reload (800 Tbps) |
|---|---|---|
| Naive full-page reload, 2s interval | 800 Tbps | baseline |
| Short polling, dedicated endpoint | ~2.6 Gbps (waste) + content | ~300,000× smaller |
| SSE, hype-moment content only | 48 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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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:
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:
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.
| Property | WebSocket |
|---|---|
| Direction | full-duplex — both sides send whenever they want |
| Handshake | HTTP Upgrade to a persistent binary-framed connection |
| Payload type | text or raw binary |
| Per-connection cost | ~28KB, versus SSE's ~20KB |
| Load balancing | sticky by construction (L4), but reconnects land on a stranger server with no shared state |
| Reconnection | not automatic — must be hand-rolled client-side, unlike SSE's built-in retry |
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.
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 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 timeout | Safe heartbeat interval | Fleet-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.
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.
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.
| Metric | What a problem looks like |
|---|---|
| Missed-pong rate | rising 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 network | a spike isolated to specific ASNs or countries usually means a proxy stripping the Upgrade header, not an application bug |
| Reconnect rate after a deploy | should spike briefly and recover; a sustained elevated rate means the new server population is rejecting handshakes it shouldn't |
| Inbound-vs-outbound frame ratio | a 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.
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:
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.
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.
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.
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:
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.
| Scenario | Reconnect rate | Handshake 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.
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.
| Property | Short poll (2s) | Long poll (25s) | SSE | WebSocket |
|---|---|---|---|---|
| Steady-state QPS | 500,000 | ~108,900 | ~0 | ~0 |
| Concurrently held conns | ~0 | ~1,000,000 | ~1,000,000 | ~1,000,000 |
| Memory per conn (held) | n/a | a few KB (event-loop) | ~20KB | ~28KB |
| Avg latency to new comment | ~1 sec | near-immediate | near-immediate (network only) | near-immediate (network only) |
| Client → server channel | separate POST | separate POST | separate POST | same connection |
| Built-in reconnect + resume | n/a (stateless) | no — hand-rolled | yes — Last-Event-ID | no — hand-rolled |
| Restrictive-proxy friendliness | excellent | excellent | good | can be blocked by Upgrade-stripping proxies |
Every one of those seven rows collapses into three practical questions, asked in this order, because each one eliminates options faster than the last:
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.
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.
| Scenario | Answers to the three questions | Choice |
|---|---|---|
| This lesson's comment feed, with typing indicators | fast client→server: yes | WebSocket |
| A read-only breaking-news ticker, no client input at all | fast client→server: no · can afford held conns: yes | SSE |
| An internal dashboard behind a strict corporate proxy that blocks Upgrade | fast client→server: no · can afford held conns: no (proxy limits) | long polling |
| An admin tool with 40 total users, refreshed a few times a minute | fast client→server: no · can afford held conns: n/a at this scale · sub-second latency: not required | short 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 seconds | fast client→server: yes (typing indicators still wanted) · can afford held conns: yes | WebSocket — 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.
Chapter 1 computed overhead-per-delivery for short and long polling. Complete the comparison at the 40/sec hype rate, 1,000,000 viewers:
| Protocol | Overhead per real delivery | What that overhead buys |
|---|---|---|
| Short polling | ~4,000 bytes | simplicity, zero held-connection cost |
| Long polling | ~871 bytes | lower 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/conn | the 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.
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.
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.
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.
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.
| Fast bidirectional need? | Can afford ~1M held conns? | Sub-second latency required? | Answer |
|---|---|---|---|
| Yes | — | — | WebSocket |
| No | Yes | — | SSE |
| No | No | Yes | Long polling |
| No | No | No | Short 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?”
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.
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.
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.
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.
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.
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?
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 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.
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:
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:
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.
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.
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.
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:
A per-stream topic — stream: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 design | Inbound traffic per connection server (this stream) | Wasted fraction |
|---|---|---|
| Global (“all-comments”) | 500/sec | 92% |
| Per-stream (this lesson's design) | 40/sec | 0% |
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.
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.
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.
| Property | Redis Pub/Sub | Kafka |
|---|---|---|
| Delivery if subscriber briefly down | lost — no replay | replayable from the persisted log |
| Operational weight | lightweight, 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 margin | enormous margin |
| Right fit for this lesson's comments feed | yes — loss is tolerable, resume tokens cover the gap | overkill here, better suited to durable event logs |
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.
stream:<id>:comments · ~2,000 deliveries/sec fleet-wideRun 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:
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.
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.
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.
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.
| Metric | What a problem looks like |
|---|---|
| Broker delivery rate vs. subscriber count | should 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 count | should 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 |
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.
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.
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:
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.
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.
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.
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.
Chapter 3 settled on roughly 28KB per idle WebSocket connection. Budget 16GB of a box's RAM for connection state:
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.
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:
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:
Solve for the largest N that fits inside that budget:
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.
| Ceiling | Connections per box | Binding? |
|---|---|---|
| File descriptors | effectively unlimited (tunable) | no |
| Memory (16GB ÷ 28KB) | ~571,000 | no |
| CPU (hype-moment fan-out writes) | ~20,000 | yes |
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.
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.
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.
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 spec | Connections/box (CPU-bound) | Fleet size |
|---|---|---|
| 8-core | 10,000 | 100 boxes |
| 16-core (this chapter's working spec) | 20,000 | 50 boxes |
| 32-core | 40,000 | 25 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.
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.
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.
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.
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.
| Metric | What a problem looks like |
|---|---|
| CPU utilization during a burst, per box | approaching the 50%-of-16-cores budget means the next larger burst has no headroom |
| Connections per box vs. the 20,000 CPU ceiling | a box significantly above 20,000 is one hype moment away from queueing writes instead of delivering them promptly |
| Ring rebalance events | should 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 elsewhere | the real user-facing cost of scaling the fleet in or out |
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:
| Scenario | Viewers | Comment rate | CPU ceiling/box | Boxes needed |
|---|---|---|---|---|
| Quiet lull | 1,000,000 | 10/sec | 80,000 | 13 |
| Tonight's peak | 1,000,000 | 40/sec | 20,000 | 50 |
| Controversial call | 1,000,000 | 80/sec | 10,000 | 100 |
| Once-a-decade event | 3,000,000 | 40/sec | 20,000 | 150 |
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.
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-out | Connections/box | Boxes needed |
|---|---|---|
| 25% (conservative, lots of other work on the box) | 10,000 | 100 |
| 50% (this chapter's working assumption) | 20,000 | 50 |
| 75% (aggressive, box does little else) | 30,000 | 34 |
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.
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.
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.
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.
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.
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.
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:
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 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:
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.
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.
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.
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:
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.
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.
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:
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.
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.
| Metric | What it tells you mid-incident |
|---|---|
| Handshake attempt rate, per surviving box | should stay near the jittered ~1.1× figure, not spike toward the 22× fixed-delay figure |
| Fraction of clients still on attempt 0 vs. later attempts | a growing tail on attempt 2+ means the fleet has not yet absorbed the initial wave |
| Replay-buffer miss rate | clients 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 baseline | the actual incident-duration number to report, since it is what viewers experienced |
“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:
| Strategy | Formula | Tradeoff |
|---|---|---|
| No jitter | delay = base × 2^attempt | maximally synchronized — the storm case above |
| Equal jitter | delay = half + random(0, half), where half = window/2 | guarantees a minimum delay (never fully collapses to zero) at the cost of a narrower spread than full jitter |
| Full jitter | delay = random(0, window) | widest possible spread, lowest peak offered rate — this chapter's choice |
| Decorrelated jitter | delay = 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.
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].
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.
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.
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:
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.
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 lost | Connections dropped | Per surviving box | Fixed-delay overload | Jittered (1s window) overload |
|---|---|---|---|---|
| 1 | 20,000 | 408 | 4.1× | 0.20× (well absorbed) |
| 5 (this chapter's scenario) | 100,000 | 2,222 | 22× | 1.11× |
| 10 (a much worse event — a whole availability zone) | 200,000 | 5,000 | 50× | 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.
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.
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.
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.
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.
stream:<id>:comments (Ch. 5) · ~2,000 broker deliveries/sec fleet-wide at tonight's rateFive 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.
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.
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.
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.
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.
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:
Compare against the default scenario: viewers doubled (1M→2M) and rate doubled (40→80/sec), and connection-server count went up 4× (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.
| Quantity | 1,000,000v / 40/sec (default) | 2,000,000v / 80/sec | Ratio |
|---|---|---|---|
| Deliveries/sec | 40,000,000 | 160,000,000 | 4× |
| Egress bandwidth | 48 Gbps | 192 Gbps | 4× |
| Connection servers | 50 | 200 | 4× |
| Scenario | Viewers | Rate | Deliveries/sec | Bandwidth | Connection servers |
|---|---|---|---|---|---|
| Quiet lull | 1,000,000 | 10/sec | 10,000,000 | 12 Gbps | 13 |
| Tonight's peak (this lesson's running example) | 1,000,000 | 40/sec | 40,000,000 | 48 Gbps | 50 |
| Once-a-decade event | 3,000,000 | 40/sec | 120,000,000 | 144 Gbps | 150 |
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.
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.
| Design | What it gets right | Where it breaks at 1,000,000 viewers |
|---|---|---|
| Short polling only | simplest possible implementation | 500,000 QPS of mostly-empty requests, Chapter 1 |
| SSE, no pub/sub, direct fan-out from the write server | real push, no polling tax | the write server has no socket to any viewer, Chapter 5 |
| WebSockets, sized by memory alone | full duplex, looks cheap (2 boxes) | CPU-bound in practice at 25× more connections than it can serve, Chapter 6 |
| This lesson's design | WebSockets, pub/sub-decoupled, CPU-sized, jittered reconnect | handles a 10-box simultaneous failure at a survivable ~2.5× overload, Chapter 7 |
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.
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.
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 cost | Connections/box (CPU-bound) | Connection servers needed |
|---|---|---|
| 5µs (a leaner, more optimized write path) | 40,000 | 25 |
| 10µs (this lesson's working assumption) | 20,000 | 50 |
| 20µs (a less optimized serialization path) | 10,000 | 100 |
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.
| Piece of this lesson | Where 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 |
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).
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.
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):
| Quantity | Value | Formula |
|---|---|---|
| Deliveries/sec | 240,000,000 | 80 × 3,000,000 |
| Egress bandwidth | 288 Gbps | 240,000,000 × 150 bytes × 8 |
| Connections/box (CPU-bound at this rate) | 10,000 | Chapter 6's ceiling, halved at 2× the baseline rate |
| Connection servers needed | 300 | 3,000,000 ÷ 10,000 |
| Reconnect overload, 10 boxes down mid-event | ~2.5×, jittered | Chapter 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.
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.