esc
Anthology / Yagnipedia / Server-Sent Events

Server-Sent Events

It's Just HTTP, and That's the Trap
Technology · First observed 2006 (Opera shipped it first, which should have been the first warning) · Severity: Deceptive (presents as Trivial, matures into Existential at demo time)

Server-Sent Events (acronym: SSE) is a web technology that allows a server to push a continuous stream of updates to a browser over a single, long-lived HTTP connection, using the EventSource API. It is widely and accurately described as “the simple alternative to WebSockets,” a description that is entirely true and has ended more evenings than any falsehood ever could.

SSE works exactly the way you would hope. The browser opens an EventSource. The server responds with Content-Type: text/event-stream, declines to ever finish responding, and writes little blocks of text — data: hello\n\n — into the open pipe whenever it has something to say. The browser fires an event for each block. There is no handshake, no framing protocol, no second port, no separate library. It is HTTP that forgot to hang up. A developer can implement the entire server side in an afternoon, feel a deep and justified satisfaction, and ship it.

The satisfaction is real. The trap is also real. They are, in fact, the same thing.

“SSE is just HTTP. That’s the honest part. The trap is that HTTP/1.1 is just six lanes, and nobody mentions the lanes until they’re all full of pipes.”
The Lizard, The Road That Bore Weight

The Promise, Which Is Kept

Before the traps, it must be stated plainly, because Yagnipedia does not slander the innocent: SSE is good. It is the correct tool for a large class of problems — live dashboards, progress streams, notifications, an agent narrating its work token by token. It is simpler than WebSockets in every dimension that matters: it rides ordinary HTTP, so it traverses proxies and corporate firewalls that treat WebSockets as a personal affront; it reconnects automatically when the connection drops, with built-in support for resuming from the last event id; and it requires, on the client, a single line of code.

The Caffeinated Squirrel, presented with a requirement to stream updates to a browser, will reach for WebSockets, then a message broker, then a dedicated real-time service with its own subprocess and a Redis pub-sub backplane and a reconnection state machine. The Lizard will reach for SSE. The Lizard will be done first. This is not in dispute.

The dispute begins later, in production, on the seventh tab.

The Six-Lane Trap

This is the trap. The others are footnotes; this is the one that takes the evening.

A browser, speaking HTTP/1.1, will open at most about six connections to a single origin at once. This is a number from the 1990s, defended by no one, removed by no one, and known to almost no one until the precise moment it matters. An SSE connection is, by design, a connection that never closes. It holds one of the six lanes open forever.

You see the problem only after you fail to see it for a while. In development there is one tab and one stream; one lane of six is occupied; everything is instant; the developer concludes, reasonably, that the architecture is sound. Then comes the demo, or the power user, or simply a person who opens the app in a few tabs and clicks around quickly. Each surface opens its own long-lived stream. Five lanes, six lanes — and now the seventh request, the ordinary GET for the next page, cannot leave the browser at all. It does not reach the server. It does not appear in the logs. It sits in the street, in a queue the developer cannot see, waiting for one of the immortal pipes to die so a lane comes free.

The application appears to hang. It is not hanging. It is waiting in line at its own front door, while the server, idle and innocent, wonders why nobody is knocking.

“I instrumented the handler. Six milliseconds. The fastest thing in the building. I had accused the sprinter of being the traffic jam. The request was never slow — it was never even there. It was outside, in the road, behind six pipes that would not hang up.”
A Passing AI, recounting the night in The Road That Bore Weight

The Fix Is One Letter

The cure for the six-lane trap is HTTP/2, which multiplexes every request and every stream over a single connection, so a thousand SSE streams and the page navigation and the favicon all ride one road and nobody waits for anybody. The connection limit simply ceases to be a concept.

There is exactly one catch, and it is exquisite: browsers will only speak HTTP/2 over TLS. Which means the difference between an application that hangs for sixty seconds and an application that never hangs at all is, very precisely, the difference between http:// and https:// — a single lowercase s.

This produces the canonical SSE debugging session, documented in the field, performed in full by the author of this article: the developer observes the hang, suspects the deepest possible cause (a database lock, a thread pool, a global mutex), constructs an elaborate and confident case against the foundation of the entire system, discovers the foundation has an airtight alibi, instruments the floorboards, clocks the handler at six milliseconds, reconstructs the browser’s connection pool from first principles — and then types one letter into the address bar and watches the bug vanish, leaving behind only the towering scaffolding of an investigation into a crime that was, the whole time, a typo in a doorway.

“Fixed my link.”
riclib, closing a sixty-second hang that had survived a lock theory, an hour of instrumentation, and a full forensic reconstruction of the HTTP/1.1 connection pool, by adding an s

The Lesser Traps

The six-lane trap is the headliner. The supporting cast is also fully booked:

The Buffering Proxy. SSE is real-time only if every hop forwards each event the instant it arrives. Reverse proxies — nginx is the usual suspect — buffer responses by default, cheerfully accumulating your “real-time” events into a bucket and pouring them out in a batch, or at connection close, or never. The events were sent. The events were correct. The events are in a bucket behind a proxy that thinks it is helping. The incantation is X-Accel-Buffering: no, plus disabling proxy buffering, plus remembering to flush, plus discovering all three of these one at a time over the course of a Tuesday.

The Text-Only Velvet Rope. SSE carries UTF-8 text. Only text. A developer who attempts to stream a binary blob — an image, a protobuf, anything with a high bit set — will discover this experimentally, which is the worst way to discover anything. Base64 exists, costs you a third of your bandwidth, and judges you the whole time.

The Reconnection Stampede. EventSource reconnecting automatically is a feature right up until your server restarts and ten thousand clients, all of whom were disconnected at the same instant, reconnect at the same instant, forming a thundering herd that re-floods the server it just recovered from, like guests returning to a wedding the moment the fire alarm stops, all at once, through one door. (See Gall’s Law: the herd was not designed; it emerged.)

The One-Way Street. SSE is server-to-client. Only. The moment a requirement appears for the client to say something back, SSE has nothing for you — you send a perfectly ordinary POST like a person, on a separate request, and it is fine. But the developer who skipped this paragraph reaches instead for WebSockets, inheriting a heavier, statefuller, harder-to-proxy protocol for the privilege of avoiding a POST, which is a trade approximately as good as buying a second house to avoid walking to the mailbox.

The Deeper Lesson

The instructive thing about the six-lane trap is not the number six. It is where the bug lives. The developer’s entire training points downward and inward — toward the database, the lock, the query, the code they wrote and can therefore blame. The connection limit lives nowhere near any of that. It lives in the browser, in a default from another decade, in the gap between the client and the server where almost no one instruments. The handler is innocent. The query is innocent. Every line of code the developer could read is innocent. The crime was committed in the doorway, by the protocol, before the request ever arrived — and you cannot debug a request that never reached you by staring harder at the place it never reached.

This is why the cure feels insulting. An evening of genuine forensic skill, and the answer is a letter. But the skill was not wasted: it is precisely the discipline of ruling out the foundation, instrumenting the path, and following the timestamps that finally points the flashlight away from the code and out into the street, where the answer was standing in plain sight the entire time, holding a sign that says https.

“The simplest protocols hide the sharpest traps, because nobody reads the manual for a thing that took an afternoon. WebSockets you respect. SSE you trust. Trust is where the landmines go.”
The Lizard

Measured Characteristics

Lines of client code to open a stream:        1
Lines of documentation explaining the
  six-connection limit, on average:           0
HTTP/1.1 connections per origin, browser:     ~6 (a number from the 1990s, owned by no one)
Tabs required to weaponize this in a demo:     7
Where the hung request actually is:            the street (not the server)
Times it appears in the server logs:           0 (it never arrived)
Length of the correct fix:                     1 character
The character:                                 s
Length of the investigation that preceded it:  1 evening
Foundations falsely arrested before the fix:   1 (the database lock; airtight alibi)
Measured runtime of the "slow" handler:        6ms (fastest thing in the building)
Proxies silently buffering your real-time:     at least 1 (it thinks it's helping)
Bytes SSE can carry that aren't UTF-8 text:    0
WebSockets imported to avoid writing a POST:   too many to count
Evenings ended by the phrase "it's just HTTP": all of them

See Also