Server-Sent Events
One-way, text, and reconnecting by default: what EventSource does for you, how Last-Event-ID closes a gap, and the per-origin connection limit that only bites in the fifth tab.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
When is a one-way text stream with reconnection already built in the right answer, and what is the trap that only appears in production?
A person starts a long export and wants to watch it progress, or leaves a notifications panel open and wants new items to appear. They are receiving, not sending.
This needs pushing, so it needs a WebSocket. SSE is the old one-way thing; we may as well use the general-purpose transport since we will want two-way eventually.
"Eventually two-way" almost never arrives, and in the meantime you have written reconnection, backoff and heartbeat code that EventSource would have provided for free (Reconnect and Backoff).
- "Eventually two-way" almost never arrives, and in the meantime you have written reconnection, backoff and heartbeat code that
EventSourcewould have provided for free (Reconnect and Backoff). - The socket loses every HTTP affordance in the path — the gateway that authenticates requests, the log that records them, the proxy that compresses them — for a feature whose payload was a line of text.
- Going the other way and reaching for SSE without reading the connection rules produces the classic bug: everything works, until a user with several tabs open finds that ordinary requests to the same origin hang forever.
- Someone tries to attach an
Authorizationheader to anEventSourceand discovers the constructor takes a URL and one option. The auth design has to be made before the transport is chosen, not after (Cookies vs Script-Readable Tokens).
What is actually happening
In the browser, not in the framework.
- An
EventSourceissues an ordinary GET withAccept: text/event-stream. The server responds with that content type and simply does not end the body (Server-Sent Events in API Design covers the contract side). - The body is a line-based text format. Blank-line-separated blocks of
data:, optionalevent:, optionalid:, optionalretry:. The browser parses it — you never see the framing. - A block with no
event:field dispatches as amessageevent. A block withevent: order.createddispatches toaddEventListener('order.created', ...), which is genuine multiplexing without a discriminator field in your payload. - When the connection drops, the browser reconnects on its own after a delay the server can set with a
retry:field. Your code does nothing; theonerrorhandler fires and the browser is already retrying (Reconnect and Backoff). - On reconnect the browser sends the last
id:it saw as aLast-Event-IDrequest header. If the server honours it by replaying from that position, the gap closes without a single line of client code (Resynchronisation After a Gap). - The stream is UTF-8 text only. Binary payloads must be encoded, which is a real cost for anything image- or buffer-shaped (Structured Clone and Transferables explains why base64 in a text channel is rarely free).
What this makes the browser do
And which of it is avoidable.
- Holding the response open occupies a connection to the origin for the life of the page. On HTTP/1.1 the browser will only open a small number of connections per origin — commonly six — and an open stream consumes one of them permanently (Reading a Network Waterfall).
- Parsing is incremental and cheap, but dispatch is not: each event is a task, and a handler that commits state per event drives one render per message (Tasks: The Unit That Cannot Be Interrupted).
- The browser retains the last event id per
EventSourceinstance and re-sends it automatically. That bookkeeping is free; deciding what to do with the replay is not. - Avoidable work: opening a stream per component instead of one per tab, and leaving it open in a hidden tab where nobody can see the results (Long-Lived Clients and Version Skew).
What `EventSource` already does
The client is genuinely this small, and the smallness is the argument. There is no reconnect loop, no backoff, no heartbeat, no id bookkeeping — the browser owns all four, and it owns them consistently across engines.
The two things you do own are visible in the code: deciding what a message means to your state, and deciding when the stream should exist at all. Both are application decisions and neither is transport work.
EventSource.CONNECTINGafter an error means the browser will retry by itself; treating that as a fatal error and building a second retry loop on top produces two competing reconnection policies.- The constructor takes a URL and
{ withCredentials }. There is no header option, and that constraint should shape the auth design rather than be discovered by it. - Every event carries
lastEventId, so your reducer always knows its position in the stream without tracking it separately (Ordering and Duplicate Delivery).
1// One instance, owned by one module. Not one per component.2const es = new EventSource('/api/stream') // cookies ride along, same-origin3 4// Named events demultiplex without a discriminator field in the payload.5es.addEventListener('order.created', (e) => {6 applyEvent(JSON.parse(e.data), e.lastEventId)7})8es.addEventListener('order.updated', (e) => {9 applyEvent(JSON.parse(e.data), e.lastEventId)10})11 12// onerror does NOT mean "give up". The browser is already reconnecting;13// readyState tells you which state it is in.14es.onerror = () => {15 setConnectionState(es.readyState === EventSource.CLOSED ? 'closed' : 'reconnecting')16}17es.onopen = () => setConnectionState('live')18 19// The stream is a resource with an owner and a lifetime.20window.addEventListener('pagehide', () => es.close())What is absent is the lesson: no timer, no attempt counter, no jitter, no Last-Event-ID tracking. es.readyState distinguishes "reconnecting" from "permanently closed", which is the distinction the UI needs to render honestly.
The wire format, and the header that closes the gap
data, event, id, retry) and the comment form are specified in HTML's event stream parsing rules and are identical in every browser; what varies is whether the *server* implements Last-Event-ID replay at all, which is a backend contract decision rather than a browser one.The format is deliberately trivial: UTF-8 lines, blank-line-separated blocks, four defined field names. Being able to read it with curl is a genuine operational advantage over a framed binary protocol, and it means the stream can be debugged with the same tools as everything else HTTP.
id: is the field that matters most and is skipped most often. It is what the browser echoes back as Last-Event-ID on reconnection, and it is therefore the only mechanism by which a gap can be closed without extra code. A server that emits data without ids has built a transport that silently loses messages on every disconnect.
1HTTP/1.1 200 OK2Content-Type: text/event-stream3Cache-Control: no-store4Connection: keep-alive5 6retry: 30007 8id: 10419event: order.created10data: {"id":"o_88","total":1299}11 12id: 104213event: order.updated14data: {"id":"o_88","status":"paid"}15 16: keep-alive comment, defeats idle timeouts, dispatches nothing17 18--- connection drops here; the browser waits, then re-requests ---19 20GET /api/stream HTTP/1.121Accept: text/event-stream22Last-Event-ID: 104223Cookie: session=...retry: sets the browser's reconnection delay in milliseconds — the one place a real millisecond number belongs, because it is a protocol field rather than a claim about performance. A line beginning with : is a comment that keeps intermediaries from declaring the connection idle.
The limit that only bites in the fifth tab
This is the trap. On HTTP/1.1 a browser will hold only a small number of simultaneous connections to a single origin, and an open stream occupies one of them for as long as the page lives. One tab is fine. Several tabs of the same application, each holding a stream, exhaust the budget — and the symptom is not "the stream broke" but "every other request to this origin hangs", which sends the investigation in entirely the wrong direction.
The fix is usually protocol rather than code: over HTTP/2 and HTTP/3 the stream is one multiplexed stream on a shared connection and the constraint largely dissolves. Where that is not available, the answers are one stream per tab, closing on hide, or sharing a single stream across tabs via a shared worker (When a Worker Is Actually the Answer).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| User opens the app in several tabs over HTTP/1.1 | Unrelated requests to the same origin queue forever; the app appears frozen | Each tab holds a stream, consuming the small per-origin connection budget | Serve over HTTP/2 or HTTP/3; otherwise one stream per tab, closed on hide, or shared through a shared worker (Web Workers and the DOM Boundary). |
| A gateway or proxy buffers response bodies | The connection is open and no events ever arrive | The intermediary waits for a complete response before forwarding anything | Disable buffering for the stream route; confirm from the customer network, not from a laptop (Forward and Reverse Proxies in Networking). |
Server closes idle streams on a timeout, with no retry: hint | A reconnect every few seconds and a request rate nobody planned | The default reconnection delay is short, and the server has not asked for a longer one | Emit retry: and periodic comment lines so the connection is never idle long enough to be reaped (Timeouts in Backend Engineering). |
Server ignores Last-Event-ID | Events that happened during a disconnect never appear; state quietly diverges | Reconnection is a browser feature; resumption is a server feature, and only one of them was implemented | Replay from the id, or return a fresh snapshot on connect and let the client rebuild (Resynchronisation After a Gap). |
| Replay after a long disconnect | A burst of hundreds of events, a long task, and a frozen page at the worst moment | One state commit and one render per event | Batch the backlog into a single commit before rendering (Yielding and Scheduling). |
| Token passed in the stream URL | Credential visible in access logs and referrers | EventSource cannot send custom headers, so the token was put where it could go | Prefer same-origin cookies; if a URL token is unavoidable, make it single-use and short-lived (Short-Lived Credentials in Security). |
How to build it
Most important first.
- Emit
id:on every event, always. It costs the server nothing and it is the entire resumption story; without it, a reconnect silently starts from "now" and the gap is invisible (Ordering and Duplicate Delivery). - Use named events for distinct kinds of message rather than one
messagetype with akindfield. The browser demultiplexes for you and the handlers stay small. - Open exactly one stream per tab, in one module, and let features subscribe to it. Several
EventSourceinstances to the same origin is how the connection limit gets hit (Who Owns This State?). - Close it when the tab is hidden for a long time and reopen on visibility, if the feature tolerates it. This is both a resource decision and an accessibility one — nothing needs announcing to somebody who is not there.
- Decide the auth story explicitly: same-origin cookies are the simple path, since
EventSourcesends them and cannot send custom headers. A short-lived token in the URL leaks into access logs and referrers, so treat it as a last resort with a short lifetime (What the Frontend Is Responsible For in Auth). - Serve the stream over HTTP/2 or HTTP/3 where you can. Multiplexing removes the per-origin connection problem almost entirely and changes nothing else about the code (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A stream that delivers items into a live region will announce every one of them. For a notification feed that is a screen reader reading a scrolling list aloud continuously, which is worse than silence because it cannot be interrupted meaningfully (Live Regions and Announcement).
- Announce a batched summary on a cadence rather than each event: a polite status region that changes to "4 new items" is usable; four separate announcements are not.
- For streamed progress — an export, a model response — announce start and completion, and leave the intermediate stream unannounced or announce it on a slow interval. Continuous text arrival is the hardest case in this whole area (Streaming a Response Without Melting the Device).
- Never prepend new items above the user's reading or focus position without warning. Appending below, or buffering behind a "show new items" control the user activates, preserves their place (Focus Management).
What can go wrong
- The per-origin connection limit on HTTP/1.1: several tabs each hold a stream, the budget is exhausted, and every other request to that origin queues indefinitely. It presents as "the app hangs after a while", which points investigation at everything except the stream.
- A buffering proxy or gateway that will not flush a partial response. Locally perfect, silent through the customer's network appliance.
- A server that closes the stream on a timeout without a
retry:hint, producing a reconnect every few seconds and a request rate no one intended (Retry Storms: The Load You Generated Yourself in Observability). - Honouring
Last-Event-IDonly for a short retention window, then silently starting from "now" when the client asks for something older. The client believes it resumed; it did not (Resynchronisation After a Gap). - The mitigation failing: emitting a keep-alive comment line to defeat idle timeouts, and setting the interval longer than the shortest intermediary timeout in the path, so it defeats nothing.
- The initial snapshot fetch and the first streamed events race. If the snapshot is requested first but resolves after event 41 has already been applied, applying the snapshot naively rolls the UI backwards (Out-of-Order Responses).
- A reconnect with
Last-Event-IDcan deliver events the client already applied, because the server replays from the last id it was *told* about rather than the last one you processed. Duplicates are the normal case, not an anomaly (Ordering and Duplicate Delivery). - Closing and reopening on visibility change races with in-flight events: the close can land after the server has queued events for a connection that is going away.
- A same-origin
EventSourcesends cookies automatically, so the stream is authenticated exactly like any other request to that origin — and, like any other request, that means the server must re-check authorization rather than trusting the connection (Authorization-Aware UI). - Cross-origin streams follow the same response-access rules as
fetch: the origin serving the stream must opt in, and credentials requirewithCredentialsplus explicit server agreement (CORS). connect-srcin a Content-Security-Policy constrains which origins anEventSourcemay connect to at all, which limits where an injected script can exfiltrate to (Content Security Policy).- Every
data:payload is attacker-influenced input arriving directly into your application. Escaping and sanitisation apply identically to pushed content and fetched content (Sanitization and Trusted HTML). - A token in the stream URL ends up in server access logs, in proxy logs and potentially in a
Referer. If a token must travel that way, make its lifetime short enough that a log leak is not a session takeover (Session Hijacking).
- "SSE is deprecated / legacy." It is a current part of the HTML standard, well supported, and the default choice for one-way text — including for streamed model output, which is the most visible real-time UI being built right now.
- "
EventSourcereconnects, so I do not need to think about disconnection." It reconnects; it does not tell you what you missed. Resumption is the server honouringLast-Event-ID(Resynchronisation After a Gap). - "The connection limit is a specification rule." It is a browser implementation behaviour on HTTP/1.1, it differs between browsers, and it mostly disappears on HTTP/2 — which is why the advice depends on your protocol version, not on your framework.
- "I can send an auth header." You cannot.
EventSourcetakes a URL and awithCredentialsflag; the auth design has to fit that shape. - "One stream per component keeps things modular." It keeps things modular right up to the point where six components on one page exhaust the origin's connection budget.
Measuring it, and what changes in the field
- The Network panel shows the request with an
eventsourcetype and a dedicated messages view listing each event with its type, id and data — the closest thing to a debugger this transport has (Debugging the Network). - A request that reappears in the list every few seconds is a reconnect loop, not a stream. Count reconnects per session in the field, because locally there are none (Real User Monitoring).
- The number of simultaneously open connections to your origin, per user, across tabs. This is the metric that predicts the connection-limit failure before a customer reports it.
- Main-thread time in event handlers when the stream bursts; a replayed backlog after a reconnect is the worst case and is exactly when the user is watching (Long Tasks).
- On HTTP/1.1 the per-origin connection budget makes multi-tab use the limiting factor; on HTTP/2 and HTTP/3 the stream is one multiplexed stream among many and the practical ceiling is far higher, though still finite and set by the server.
- On a mobile network the connection drops routinely, so
Last-Event-IDsupport on the server is not a nicety — it is the difference between resumption and a silent gap on every tunnel and lift (Resynchronisation After a Gap). - In a background tab the stream stays open and events keep arriving, doing work nobody can see. On a low-memory device the tab may be discarded entirely and the stream never resumes (The Multi-Process Browser).
- With a large replay backlog, reconnection delivers hundreds of events in one burst, which is a rendering problem rather than a networking one (List Virtualization).
- You get reconnection and resumption for free and give up the client-to-server direction entirely: anything the client sends is a separate HTTP request, which is usually fine and occasionally awkward.
- Text-only means binary payloads must be encoded, paying roughly a third in size plus encode and decode cost on both ends.
- One connection per tab is a real resource. The cheapest mitigation — closing on hide and reopening on show — introduces gaps that must then be resynchronised.
- Server-side, a held-open response per client constrains your process model; that constraint belongs to the backend but it is created by this frontend choice (Writing Event Consumers in Backend Engineering).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe
EventSourceAPI, thetext/event-streamframing, automatic reconnection and theLast-Event-IDheader are all specified in HTML and behave the same across Blink, Gecko and WebKit; what differs is devtools presentation and connection accounting. - NETWORK-SPECIFICThe per-origin connection cap applies to HTTP/1.1, where a held-open stream consumes one of a small budget (commonly six in current browsers); over HTTP/2 and HTTP/3 the stream is multiplexed and the effective limit is the server's max concurrent streams, which is typically an order of magnitude higher.
- PLATFORM-SPECIFICIntermediaries decide whether a stream works at all: a proxy or gateway that buffers response bodies will deliver nothing until the response ends, which no browser-side change can fix and which differs per customer network rather than per browser.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — how long a server can retain an event log so that
Last-Event-IDreplay is possible, and what happens to a client that asks to resume from a position that has been compacted away.