Real-Timesseeventsourcestreaminglast-event-idnotificationsunidirectional

Server-Sent Events

One long-lived HTTP response, streaming events one way: server to client. SSE buys auto-reconnect with built-in resume (Last-Event-ID) for the price of unidirectionality — and for notifications, progress, dashboards and token streams, one way is all you needed.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
The server has a stream of events for the client — does the client need to talk back on the same channel, and if not, why carry a bidirectional protocol's costs?
Consumers
Browsers and services that watch: notification bells, live dashboards, build-log viewers, job-progress bars ([[async-job-pattern]]), and AI chat UIs rendering tokens as the model produces them.
The promise
Events arrive in order on a plain HTTP response; each carries an id; a dropped connection resumes automatically from the last id received — with the gap-replay window stated — and the client never needed more than an HTTP client to participate.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

The mechanism: HTTP, held open

SSE is barely a protocol: the client GETs an endpoint, the server answers Content-Type: text/event-stream and never finishes the response, writing id:/event:/data: lines per event as they occur. That thinness is the feature. Every piece of HTTP machinery still applies — your normal auth, your load balancers, HTTP/2 and 3 multiplexing (HTTP/1.1 vs HTTP/2 vs HTTP/3), your logging — and in the browser, EventSource handles connect, parse, and reconnect natively. The infrastructure changes are about held-open responses, not new protocols: proxy buffering off, idle timeouts raised (Polling vs Long Polling vs SSE vs WebSockets covers the ops side).

The trade is stark and often exactly right: the client cannot send anything on this channel. But look at the consumer list — notifications, dashboards, progress, token streams — and the client's only upstream traffic is commands it can send as ordinary POSTs. The "bidirectional" need in most realtime features is asymmetric: a firehose downstream, a trickle of RPCs upstream. SSE + POST serves that shape with two boring HTTP calls, where a WebSocket serves it with a stateful protocol you must design yourself (WebSocket Message Contracts).

A notification stream: one request, an unbounded response
Request
GET /v1/events HTTP/1.1
Accept: text/event-stream
Authorization: Bearer <token>
Last-Event-ID: evt_4021

# Last-Event-ID sent automatically by EventSource
# on reconnect — this *is* the resume protocol.
Response
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store

id: evt_4022
event: order.updated
data: {"order_id":"ord_3","status":"shipped"}

id: evt_4023
event: notification.created
data: {"text":"Your report is ready"}

: heartbeat            ← comment line keeps proxies alive

The resume contract: Last-Event-ID and its limits

Auto-reconnect is the headline, but the contract work hides in its second half. EventSource reconnects by itself and presents Last-Event-ID — *your* obligations are to assign meaningful, monotonic-per-stream ids, keep enough recent events to replay the gap, and define what happens when you can't. That last clause is the one teams skip: a client offline for two hours presents an id you evicted long ago. The honest designs either send a synthetic stream.reset event instructing a state refetch, or scope the stream so replay never matters (each event is self-contained and the UI's next full load heals everything).

Delivery semantics deserve one honest sentence in the docs: SSE with replay is at-least-once — a client may receive an event, drop before the id is durably noted, and receive it again on resume. Consumers therefore dedupe by event id, the same bargain as everywhere else repeats exist (Idempotency vs Deduplication). And per-event event: types are your schema surface: the same additive-evolution rules apply as for any contract — new event types must be ignorable, payload changes additive (Backward Compatibility: The Real Rules).

For AI token streaming — currently SSE's most visible job — the resume story is deliberately different: replaying half a generation is rarely useful, so streams are scoped to one request (POST then stream the completion), ids are omitted or ignored, and a drop means the client re-requests or resumes via an application-level operation (Streaming APIs: Partial Data as a Contract covers partial-result semantics; the shape is one-shot stream, not durable feed).

  • Ids are the contract — monotonic per stream, meaningful for replay; a random UUID per event wastes the entire resume mechanism.
  • Retention window, stated — "reconnects within 5 minutes replay the gap; beyond that you receive stream.reset" is a complete, checkable promise.
  • Heartbeats — a comment line every 15–30s distinguishes "quiet stream" from "dead connection" and keeps intermediaries from reaping the response.
  • `retry:` directive — the server can set the client's reconnect delay; use it to spread reconnect load after deploys.
  • At-least-once, dedupe by id — say it once in the docs and consumers build correctly the first time.

Choosing SSE, and knowing when you've outgrown it

Against polling, SSE trades a held connection for latency and waste: a 10-second poll costs a full request/response cycle per client per interval to usually learn "nothing new", and still delivers events 5 seconds late on average. Against WebSockets, SSE trades upstream capability for operational simplicity — no protocol upgrade for infrastructure to mishandle, no custom envelope/ack/resume design, reconnect for free. The decision input is your traffic's *shape*: how often does the client genuinely need to send on the same channel, with latency a POST can't meet?

The honest outgrow signals: client→server messages that are high-frequency or latency-critical (typing indicators, cursor positions, game input); tens of thousands of concurrent streams where per-connection overhead starts driving your fleet size (C10K: Ten Thousand Connections, Then a Million territory); or binary payloads, which SSE's text framing handles only via encoding overhead. Then a WebSocket — with the full contract work it demands — is the right spend. Until then, an SSE stream plus ordinary POSTs is the same product with a fraction of the surface.

Delivering server-side events: the three shapes compared
PollingSSEWebSocket
LatencyAverage = interval / 2ImmediateImmediate
Client → serverEvery requestSeparate POSTsSame channel
Reconnect + resumeFree (stateless)Built-in (Last-Event-ID)Yours to design (WebSocket Message Contracts)
Infra compatibilityEverythingHTTP + held responses (buffering off)Upgrade-aware proxies and LBs required
Protocol design burdenNoneEvent types + retention windowEnvelope, acks, seq, resume — all of it
Server cost at restRequests × clients / intervalOne open connection per clientOne open connection per client + session state

Key points

  • SSE is a held-open HTTP response — all existing auth, LB and observability machinery applies; the browser client is built in.
  • Most "realtime" needs are asymmetric: firehose down, trickle up. SSE + ordinary POSTs serves that shape without designing a protocol.
  • Last-Event-ID gives you resume for free only if you do your half: monotonic ids, a stated replay window, and a defined reset path beyond it.
  • SSE with replay is at-least-once — consumers dedupe by event id, and the docs should say so in one sentence.
  • Heartbeat comments and the retry: directive are the operational contract — they keep proxies honest and spread reconnect herds.
  • Outgrow signals are concrete: high-frequency upstream traffic, huge concurrent fan-out, or binary frames — then pay the WebSocket's contract costs knowingly.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → feature: "notifications must be realtime" → reaches for WebSockets by reflex, as the only realtime tool anyone names.
  2. 2
    Team → infra: builds the socket tier — upgrade-aware LB config, a message envelope, reconnect handling — for traffic that is 99.9% server→client.
  3. 3
    Clients → protocol: each client team implements the custom resubscribe/resume logic slightly differently; the bugs are all in the 0.1%.
  4. 4
    Ops → incident: a proxy that silently drops idle upgraded connections strands clients in half-open states; nobody's dashboard shows it.
  5. 5
    Team → retrospective: the notification bell — a textbook SSE feature — carries a bidirectional protocol's complexity forever, because migrating off is now a client-coordination project.
What breaks
  • Streams without heartbeats die silently in proxies; clients wait on dead connections and miss events with no error to react to.
  • Ids without retention (or retention without a reset event) turn every long disconnect into a silent gap — the dashboard is wrong and looks fine.
  • Buffering proxies between server and client batch "realtime" events into 30-second clumps; the feature works in dev and fails only through the production path.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Default to SSE for server→client streams and POSTs for the upstream trickle; escalate to WebSockets only on the named outgrow signals.
  • • Assign monotonic per-stream event ids, state the replay window, and define the beyond-window behavior (`stream.reset` → refetch) as an explicit event.
  • • Send heartbeat comments on a stated interval and use `retry:` to control reconnect spread; document both as client-visible behavior.
  • • Type every event (`event:` field) and apply additive-evolution rules to event payloads exactly as to response bodies.
Observe in production
  • • Track concurrent open streams, stream age distribution, and reconnect rate — a sawtooth in stream age locates the intermediary killing your connections.
  • • Measure resume outcomes: replayed-gap vs reset-required ratios tell you whether the retention window matches real disconnect durations.
  • • Alert on event-emit-to-flush latency through the full path; a buffering hop shows up as batched delivery that origin metrics alone will never reveal.
Evolve without breaking
  • • New event types are additive once clients ignore unknown types; payloads follow standard compatibility rules ([[backward-compatibility]]).
  • • The same stream endpoint can serve new consumers with filtered subsets via query params without touching existing subscribers.
  • • If bidirectionality arrives later, the SSE stream can remain the delivery channel while a WebSocket or POST path handles upstream — migration is per-direction, not big-bang.
What it costs
  • • Held-open connections occupy server and intermediary resources per idle client; fleets sized for request/response need re-examination at high fan-out ([[slow-clients-and-backpressure]]).
  • • Unidirectionality is a hard wall: the day you need low-latency upstream on the same channel, no incremental patch provides it — that's a transport change.
  • • Replay retention is state you must size and pay for per stream class; generous windows are quietly expensive at scale.

Misconceptions

Claim
“SSE is legacy tech; WebSockets are the modern replacement.”
Reality
They solve different shapes. SSE's renaissance is visible in the highest-profile new APIs there are — LLM token streaming is SSE — because unidirectional streaming over plain HTTP is exactly what those products need, with none of the upgrade-path fragility.
Claim
“Browsers limit SSE to six connections, so it doesn't scale.”
Reality
That is the HTTP/1.1 per-host connection limit; over HTTP/2 and HTTP/3, streams multiplex over one connection and the limit dissolves. The correct response is serving SSE over h2/h3, not abandoning the model.
Claim
“Auto-reconnect means clients never miss events.”
Reality
Auto-reconnect plus *your ids, retention and reset semantics* means that. EventSource resends Last-Event-ID; whether anything meaningful happens with it is entirely the server contract's doing.

Apply it