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.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
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).
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.
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.
| Polling | SSE | WebSocket | |
|---|---|---|---|
| Latency | Average = interval / 2 | Immediate | Immediate |
| Client → server | Every request | Separate POSTs | Same channel |
| Reconnect + resume | Free (stateless) | Built-in (Last-Event-ID) | Yours to design (WebSocket Message Contracts) |
| Infra compatibility | Everything | HTTP + held responses (buffering off) | Upgrade-aware proxies and LBs required |
| Protocol design burden | None | Event types + retention window | Envelope, acks, seq, resume — all of it |
| Server cost at rest | Requests × clients / interval | One open connection per client | One 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.
- 1Team → feature: "notifications must be realtime" → reaches for WebSockets by reflex, as the only realtime tool anyone names.
- 2Team → infra: builds the socket tier — upgrade-aware LB config, a message envelope, reconnect handling — for traffic that is 99.9% server→client.
- 3Clients → protocol: each client team implements the custom resubscribe/resume logic slightly differently; the bugs are all in the 0.1%.
- 4Ops → incident: a proxy that silently drops idle upgraded connections strands clients in half-open states; nobody's dashboard shows it.
- 5Team → retrospective: the notification bell — a textbook SSE feature — carries a bidirectional protocol's complexity forever, because migrating off is now a client-coordination project.
- 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.
- • 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.
- • 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.
- • 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.
- • 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.