One-way streaming with Server-Sent Events
Open in the interactive app: practice, quiz, mock interview →In one minute
- Server-Sent Events is a one-way push channel: the client makes one ordinary HTTP GET, and the server keeps the response open and writes small text events into it for as long as it likes.
- The wire format is plain UTF-8 lines (
data:,event:,id:,retry:), with a blank line ending each event, so any HTTP server, proxy or debugging tool already understands it. - Resumption is built in: the browser remembers the last
idit saw and sends it back as theLast-Event-IDheader when it reconnects, so the server can replay what was missed. - It is the default choice for server-to-client streams such as feeds, dashboards, progress bars and LLM token streaming; use WebSockets only when the client must also send a high rate of messages on the same channel.
- Most production failures are not in the protocol but in the middle: proxies that buffer, idle timeouts that cut quiet streams, and the six-connection limit of HTTP/1.1.
How it works
A response that never finishes
A normal HTTP response has a length and an end. An SSE response has neither. The client sends GET /events with Accept: text/event-stream; the server answers 200 OK with Content-Type: text/event-stream and Cache-Control: no-cache, and then simply does not close the body. Each time something happens, it writes a few lines and flushes.
event: price
id: 1842
data: {"sym":"ACME","bid":101.25}
: keepalive
data: first line
data: second line
The first block is a price event with id 1842. The colon line is a comment, sent every 15 seconds or so (the spec's own suggestion) so idle connections are not killed by proxies (WHATWG HTML). The last block has no type, so it arrives as a message event whose data is the two lines joined with a newline.
The browser side
In a browser, new EventSource("/events") opens the stream, parses it, and fires a DOM event per block. addEventListener("price", fn) receives typed events; onmessage receives untyped ones. If the connection drops, EventSource waits the retry interval (implementation-defined, "probably in the region of a few seconds" per the spec, and overridable by a retry: line from the server (WHATWG HTML)) and reconnects by itself, adding Last-Event-ID: 1842. The server reads that header and replays everything after 1842 before going live. That remember, reconnect and replay loop needs no client code.
Many clients read SSE with fetch and a stream reader instead. That is how most LLM APIs are consumed: a POST with the prompt, answered with a text/event-stream of token deltas. EventSource cannot send a body or custom headers (its only option is withCredentials for cross-origin cookies), so these clients reimplement the parser and the reconnect loop.
The Model Context Protocol shows where this is heading. Its first remote transport (2024-11-05) was "HTTP+SSE": one long-lived GET stream plus a separate POST endpoint. The 2025-03-26 revision replaced it with Streamable HTTP, a single endpoint where every client message is a POST and the server may answer any POST with either plain JSON or a text/event-stream, and may resume a broken stream from Last-Event-ID (MCP spec). SSE stayed as the framing; the always-open connection became optional.
What SSE is good at
SSE fits the fan-out problem shape: one producer, many passive readers, messages flowing mostly downward. Live scores, build logs, order status, feature-flag updates and model output all look like this. The client's rare writes (posting a comment, cancelling a job) go over ordinary HTTP requests, which stay stateless and easy to authenticate.
The same shape appears in a second domain: a database change feed (Postgres logical replication, a Kafka consumer) is one long-lived ordered stream with a position the reader stores and resumes from. Last-Event-ID is that position, held by the browser instead of a consumer group.
Down the stack
Layer 1The contract
The server promises an ordered stream of events over one HTTP response. Each event has a type (default message), a data string, and the last id seen so far. Nothing is promised about delivery: if the TCP connection breaks, events in flight are lost unless the server can replay from Last-Event-ID. So the honest contract is at-most-once per connection and, with ids and a replay buffer, at-least-once across reconnects, which means clients should treat events as idempotent or deduplicate by id.
Two status rules matter. A response that is not 200 with the text/event-stream type fails the EventSource permanently, with no retry (WHATWG HTML). A 204 No Content is the polite way for a server to say "stop reconnecting". Redirects (301, 307) are followed like any request. Everything else (network errors, a closed stream) triggers reconnection after the retry delay.
Layer 2The mechanism
The WHATWG HTML standard defines the parser precisely. Lines end with CRLF, LF or a lone CR. For each line: an empty line dispatches the pending event; a line starting with : is a comment; otherwise the text before the first colon is the field name and the rest is the value, with one leading space removed. A line with no colon is a field with an empty value.
Fields update buffers. data appends its value plus a newline to the data buffer. event sets the type buffer. id sets the last-event-id buffer, unless the value contains a NUL. retry sets the reconnection time, but only if the value is all ASCII digits. Unknown fields are ignored, which is how the format stays forward compatible.
On dispatch, the parser first copies the last-event-id buffer into the connection's last event id, even if no event will fire. If the data buffer is empty, it resets and fires nothing; so id: 99 followed by a blank line is a valid way to move the resume point without an event. Otherwise it strips one trailing newline from the data, fires an event with the type (or message), and clears the data and type buffers. The id is not cleared; it carries forward until a new id line replaces it. If the stream ends in the middle of an event, that partial event is discarded.
Layer 3The machine
On HTTP/1.1 each flush becomes a chunk of chunked transfer encoding: the length in hex, CRLF, the bytes, CRLF. A 60-byte event costs about 66 bytes of HTTP framing plus a 40 to 52 byte TCP/IP header, and is usually one segment. With Nagle's algorithm on, a small write can sit in the kernel for up to one round trip waiting for an ACK, so streaming servers set TCP_NODELAY. On HTTP/2 each flush is a DATA frame with a 9-byte header on a stream inside one shared connection.
An idle SSE connection on the server is a file descriptor registered with epoll, about 1 to 4 KB of kernel socket state, plus whatever the runtime keeps: a 2 to 8 KB goroutine stack in Go, a few hundred bytes of closure in Node, or a whole thread (about 1 MB of reserved stack) in a thread-per-request server. That is why SSE on a thread pool falls over at a few thousand clients while an event-loop server holds hundreds of thousands. Language runtimes add their own cost: LinkedIn measured about 20 KB of JVM heap per TLS connection on its SSE messaging tier, and Linux's old somaxconn default of 128 capped how many clients could connect at once until they raised it (LinkedIn Engineering). Send buffers matter too: if a slow client stops reading, the kernel buffers up to wmem bytes (hundreds of KB with autotuning), then write blocks or returns EAGAIN, and the application must decide whether to queue, drop or disconnect.
Why it's built this way
- Browsers and the web's middle boxes already spoke HTTP, so a push channel that was just a slow HTTP response could ship without new ports, new proxies or new firewall rules.
- Text lines are easy to parse incrementally from a stream that arrives in arbitrary chunks, and easy to read in
curl, so the format is line-based UTF-8 rather than binary. - Networks drop long connections often, so automatic reconnect had to be part of the client, and reconnects lose data, so the id and
Last-Event-IDresume point came with it. - A one-way channel never parses client input on the long-lived connection, which keeps the stateful part small and pushes writes onto stateless requests.
Diagram
sequenceDiagram
participant B as Browser
participant P as "Reverse proxy"
participant S as "Event server"
B->>P: GET /events (Accept text/event-stream)
P->>S: GET /events
S-->>B: 200 text/event-stream
S-->>B: id 41, data
S-->>B: id 42, data
S-->>B: ": keepalive" comment
Note over B,P: connection drops
B->>P: GET /events, Last-Event-ID 42
P->>S: GET /events, Last-Event-ID 42
S-->>B: replay 43, 44 from buffer
S-->>B: live events continueWorked example
A sports app pushes score updates to 400,000 concurrent viewers during a final. Each update is about 120 bytes with framing, and there are 2 updates per second during play.
Bandwidth is 400,000 x 2 x 120 bytes, about 96 MB/s or 770 Mbit/s of egress before TLS. Keepalives every 20 seconds add about 400 KB/s, which is noise. Memory is the real cost: at about 10 KB per connection across kernel buffers, TLS state and the runtime, 400,000 connections need about 4 GB, so a fleet of eight event-loop servers at 50,000 connections each holds it with headroom for losing one.
Reconnect storms are the dangerous moment. If one server restarts, 50,000 clients come back at once, all with Last-Event-ID. With a fixed 3-second retry, they arrive within the same second. The fix is to send retry: with a per-client random value between 2,000 and 10,000 ms, spreading the storm over 8 seconds, and to serve replays from an in-memory ring buffer of the last 60 seconds of events (120 events, a few KB) instead of a database query per client.
async def stream(request):
last = request.headers.get("Last-Event-ID")
yield f"retry: {random.randint(2000, 10000)}\n\n"
for ev in ring.since(last): # replay from memory
yield f"id: {ev.id}\ndata: {ev.json}\n\n"
async for ev in hub.subscribe(): # then go live
yield f"id: {ev.id}\ndata: {ev.json}\n\n"In the wild
- Shopify, 2022: dropping WebSockets and polling for SSE. The 2021 Black Friday Cyber Monday Live Map pushed data over a WebSocket into a mailbox that browsers polled at least every 10 seconds, and trending products could take minutes to update. For 2022 Shopify rebuilt it as a Go SSE server reading Kafka behind nginx, with JWTs sent in an
Authorizationheader through an EventSource polyfill. Data reached browsers within milliseconds of being ready and within 21 seconds of the sale end to end, with 100% uptime over the weekend (Shopify Engineering). - LinkedIn, 2016: 100,000 SSE connections per node. LinkedIn chose SSE over WebSockets for instant messaging because it is plain HTTP and works across members' networks. Load tests hit a 128-connection accept backlog, a per-process file-descriptor limit that capped connections per node, a thread leak from one idle-timeout timer per connection, and about 20 KB of heap per TLS connection; after raising the limits and the heap to 16 GB each node held about 100,000 persistent connections (LinkedIn Engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| SSE | Plain HTTP, auto reconnect and resume, works through most proxies, trivial to debug | One-way, text only, EventSource cannot set headers | Server pushes, client rarely writes |
| WebSockets | Full duplex, binary frames, low per-message overhead | Own framing and reconnect logic, harder through proxies and to load balance | Chat, games, collaborative editing |
| Long polling | Works everywhere, each response is a normal request | A request per batch, latency spikes between polls | Fallback or very low event rates |
| Short polling | Simplest, cacheable | Wasted requests, latency equals the interval | Updates every minute or slower |
Pitfalls in production
- A reverse proxy buffers the response and the client sees nothing for minutes. Buffering is on by default in nginx (nginx docs). Turn it off for the route (
proxy_buffering off, or sendX-Accel-Buffering: no) and do not gzip the stream unless the compressor flushes per event. - Load balancer idle timeouts (60 seconds by default on AWS ALB (AWS docs)) cut quiet streams. Send a comment line well inside the shortest timeout on the path.
- On HTTP/1.1 a browser opens at most six connections per browser and domain, shared across tabs, so a user with seven tabs open hangs; Chrome and Firefox marked this "Won't fix". HTTP/2 negotiates the stream limit instead, 100 by default (MDN). Serve SSE over HTTP/2, or share one stream across tabs with a
SharedWorkerorBroadcastChannel. - Ids that are not replayable are worse than none: the client sends
Last-Event-ID, the server cannot find it, and silently starts live. Use monotonically increasing ids backed by a buffer, and send a "resync" event when the id is too old so the client refetches state. - Auth tokens in query strings leak into access logs. Prefer a cookie, or a short-lived single-use stream ticket obtained by a normal authenticated request.
Leadership lens
- Default to SSE for server-to-client streams and ask teams proposing WebSockets which client-to-server messages actually need the same connection.
- The cost driver is concurrent connections, not requests per second. Capacity plans, dashboards and alerts should track open streams per node and reconnect rate.
- Decide the replay window as a product decision: how long a client can be offline and still resume, versus a full refetch. Write it down next to the id scheme.
- Every proxy, CDN and balancer in the path is now part of the feature. Name an owner for those settings and test streaming end to end in staging.
Check yourself
A server sends
id: 10\n\nwith no data line. What does the client do, and why is that useful?Show answer
No event fires, because the data buffer is empty, but the client's last event id becomes 10. The server can advance the resume point (for example after events the client does not need) without sending a payload.
Why does an LLM API that streams over SSE typically not work with the browser
EventSourceobject?Show answer
The API needs a POST body and an
Authorizationheader, andEventSourceonly issues a GET with cookies. Clients usefetchwith a stream reader and their own SSE parser instead.Users report that live updates arrive in large bursts every few minutes in production but stream smoothly locally. What is the likely cause?
Show answer
Something in the path is buffering the response, usually a reverse proxy, a CDN or a compression layer. Disable buffering and compression for the stream route.
Is SSE delivery exactly-once across reconnects?
Show answer
No. Events in flight when the connection drops can be lost, and replay from
Last-Event-IDcan resend events the client already applied. With ids and replay you get at-least-once, so clients must deduplicate or apply idempotently.
Sources and further study
Primary
- WHATWG HTML: Parsing an event stream: the ABNF for the format and the exact dispatch algorithm.
- MCP: Streamable HTTP transport (2025-03-26): a current protocol that uses SSE per request with resumable ids.
- RFC 9113, section 6.5.2: SETTINGS_MAX_CONCURRENT_STREAMS: why many SSE streams can share one HTTP/2 connection.
Systems docs
- OpenAI: Streaming API responses: another LLM API that streams typed semantic events over SSE.
- AWS: ALB connection idle timeout: the default that decides how often heartbeats must go out.
Books
- High Performance Browser Networking, Ilya Grigorik, chapter 16 "Server-Sent Events (SSE)": the EventSource API, the event stream protocol and its performance limits (read free).
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, chapter 11 "Stream Processing": consumer offsets, the same idea as
Last-Event-ID.
Other curricula
- Azure Architecture Center: Asynchronous Request-Reply: recommends SSE over polling when responses must stream in real time.
- roadmap.sh: Backend: lists Server Sent Events alongside WebSockets and long/short polling under "Real-Time Data".
Watch
- Server-Sent Events Crash Course (Hussein Nasser): builds an SSE server and client and discusses the trade-offs against WebSockets and polling.
Free resources
- Server-sent events (WHATWG HTML Standard): the normative parsing and reconnection rules this lesson's practice implements.
- Using server-sent events (MDN): field-by-field format reference with client and server examples.
- EventSource (MDN): the browser API, its ready states and its limits.
- Stream Server-Sent Events updates (web.dev): a practical walkthrough of ids, retry and reconnection.
- Streaming Messages (Anthropic docs): a real LLM API that streams typed events over SSE.
- ngx_http_proxy_module (nginx):
proxy_buffering,X-Accel-Bufferingand read timeouts that decide whether a stream gets through.