Engineering Atlas › System Design Fundamentals

One-way streaming with Server-Sent Events

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

  1. 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.
  2. 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.
  3. 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-ID resume point came with it.
  4. 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 continue

Worked 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

Trade-offs

OptionStrengthsWeaknessesChoose when
SSEPlain HTTP, auto reconnect and resume, works through most proxies, trivial to debugOne-way, text only, EventSource cannot set headersServer pushes, client rarely writes
WebSocketsFull duplex, binary frames, low per-message overheadOwn framing and reconnect logic, harder through proxies and to load balanceChat, games, collaborative editing
Long pollingWorks everywhere, each response is a normal requestA request per batch, latency spikes between pollsFallback or very low event rates
Short pollingSimplest, cacheableWasted requests, latency equals the intervalUpdates every minute or slower

Pitfalls in production

Leadership lens

Check yourself

  1. A server sends id: 10\n\n with 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.

  2. Why does an LLM API that streams over SSE typically not work with the browser EventSource object?

    Show answer

    The API needs a POST body and an Authorization header, and EventSource only issues a GET with cookies. Clients use fetch with a stream reader and their own SSE parser instead.

  3. 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.

  4. 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-ID can 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

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →