What Is Server-Sent Events? The Beginner's Guide (EventSource, text/event-stream, and the LLM-Streaming Comeback)
CodexFuse Team · 2026-11-04T12:30:58.116Z · 13 min read min read
What Is Server-Sent Events? The Beginner's Guide (EventSource, text/event-stream, and the LLM-Streaming Comeback)
What is Server-Sent Events, and why did a quiet corner of the HTML standard suddenly become the busiest protocol in AI? Server-Sent Events (SSE) is a web standard that lets a server push a stream of text events to a browser over a single, long-lived HTTP connection. The browser listens with a built-in API called EventSource — no WebSocket handshake, no polling loop, no plugins. If you have ever watched an AI chatbot type its answer word by word, you have watched Server-Sent Events at work.
This guide explains server-sent events from zero: what the text/event-stream format looks like on the wire (with a real captured stream), how EventSource auto-reconnects, how SSE compares to WebSocket and long polling, and why every major LLM API streams tokens over SSE in 2026. If you are weighing SSE against its bidirectional cousin first, open our WebSocket tester in another tab — poking a live ws:// connection for thirty seconds makes SSE's one-way design click instantly.
What Are Server-Sent Events, Exactly?
The direct answer: Server-Sent Events is a W3C/WHATWG-standardized way for a server to push text events to a web client over one ordinary HTTP connection that stays open. The client creates an EventSource object pointed at a URL, and the server answers with Content-Type: text/event-stream and then keeps writing framed events instead of closing the response.
The WHATWG HTML Standard defines the whole API in section 9.2: the browser announces the connection, then dispatches each incoming event to your JavaScript the moment it arrives. The default event type is "message", so a handler like source.onmessage fires for every untyped event. Named events let one stream carry several kinds of updates — a job-start here, a progress there — each with its own listener.
One property defines everything else about SSE, so learn it first: it is strictly unidirectional, server to client. The client can never send messages back over the same stream; commands go in through a separate request, and status updates come out through SSE.
How Does SSE Work, Step by Step?
The five-step version of every SSE connection:
- The browser opens the stream with one line:
const source = new EventSource("/events");The WHATWG spec has the browser send anAccept: text/event-streamheader with ano-storecache mode. - The server responds with HTTP 200,
Content-Type: text/event-stream, andCache-Control: no-cache— and does not close the connection. - As things happen, the server writes events as small blocks of UTF-8 text, each block ending with one blank line.
- The browser parses each block the moment the blank line arrives and fires a DOM event, so your page updates instantly.
- If the connection drops, the browser waits the server's chosen
retryinterval and reconnects on its own, optionally resuming from the last event it saw.
The text/event-stream Format, Decoded
The direct answer: an SSE stream is plain UTF-8 text made of field lines, one event per blank-line-terminated block. A line that starts with a colon is a comment the client ignores — servers use them as cheap keep-alive pings.
Here is a real stream, captured with curl from a test SSE server running on 2026-11-04, exactly as the bytes arrived:
HTTP/1.0 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
: keep-alive comment - ignored by the client
retry: 5000
event: job-start
id: 101
data: {"job": "nightly-backup", "at": "04:00"}
data: plain message without a type
event: progress
id: 102
data: first line of the report
data: second line of the report
Read it like the browser does. The comment line keeps proxies and load balancers from timing out an idle connection — MDN notes this keep-alive trick explicitly. The bare retry: 5000 block applies to the whole stream: if the socket dies, wait five seconds before reconnecting. Then come two named events (job-start, progress) and one untyped message, each separated by its blank line. Notice the last event's two data: lines: the browser joins them into one value with a newline between, so event.data becomes "first line of the report\nsecond line of the report".
The 4 Fields of Every SSE Event
The direct answer: every SSE event is built from at most four meaningful fields, each one a name: value line. The WHATWG spec and MDN agree on exactly these:
| Field | What it does | Example |
|---|---|---|
event | Names the event type so the client can route it to a dedicated listener. If omitted, the event type is "message". | event: progress |
data | Carries the payload. Multiple data: lines are concatenated with a newline between each; trailing newlines are removed. | data: {"pct": 42} |
id | Sets the client's last-event-ID. On reconnect the browser sends it back in a Last-Event-ID header so the server can resume. | id: 102 |
retry | Milliseconds the browser waits before reconnecting after a drop. Must be an integer — anything else is ignored. | retry: 5000 |
Any other field name is silently ignored, and a line with no colon at all is treated as a field name with an empty value. The format is intentionally tiny: there is no envelope, no framing bytes, nothing to decode beyond the lines themselves.
EventSource API Tutorial: Connect in 10 Lines
The direct answer: connecting is one constructor call, and receiving is one event handler. This is the entire client side of SSE:
const source = new EventSource("/events");
// Fires for every event with no "event:" field
source.onmessage = (event) => {
console.log("Message:", event.data);
};
// Fires only for "event: progress" blocks
source.addEventListener("progress", (event) => {
updateProgressBar(JSON.parse(event.data));
});
source.onerror = () => console.error("Stream failed");
// Stop listening entirely
function stop() { source.close(); }
MDN's guide adds three practical details worth knowing. First, cross-origin streams work with withCredentials: true if you need cookies. Second, EventSource only does GET — if you need a POST body or custom request headers, use fetch() and parse the response body as a stream yourself, though then reconnection becomes your code's job. Third, an error event fires on network failures, and by default the browser just reconnects — your onerror handler is for logging and UI, not for reconnect logic.
How EventSource Reconnects (and Where It Can't Help You)
The direct answer: when the connection closes, the browser automatically waits the retry interval and reconnects, sending a Last-Event-ID header carrying the last id it received. The default wait is implementation-defined — the WHATWG spec says "probably in the region of a few seconds" — and browsers may add exponential backoff to avoid hammering a struggling server.
Two spec details make reconnect predictable. A server that answers a reconnect with HTTP 204 No Content tells the browser to stop trying. And 301/307 redirects are followed, so you can move a stream endpoint without breaking clients. The coinranking engineering blog demonstrates the common pattern: send retry: 5000 as the first block so every client waits five seconds after a drop.
But — and this is the lesson a 2026 dev.to walkthrough states better than anywhere else — "reconnecting is not the same as recovering." The browser does not store missed events for you; the event ID only resumes if your server kept the history. If a missed update matters, your backend needs retained events and a replay policy, or a way for the client to fetch current state after reconnecting. Plan the resume path, not just the reconnect.
SSE vs WebSocket vs Long Polling: The Decision Table
The direct answer: choose by direction and protocol cost. SSE is one-way over plain HTTP, WebSocket is bidirectional over its own protocol, and long polling is one-way built from repeated HTTP requests:
| SSE | WebSocket | Long polling | |
|---|---|---|---|
| Direction | Server → client only | Both ways | Server → client, via repeated client requests |
| Protocol | Plain HTTP, text/event-stream | ws:// upgrade + binary frames | Plain HTTP, many requests |
| Reconnect | Built in, with Last-Event-ID resume | You write it | Every request is a new connection |
| Latency feel | Instant push | Instant, lowest overhead | Near-instant, with request churn |
| Best for | Feeds, progress bars, notifications, AI token streams | Chat, games, collaborative editing | Legacy setups, strict proxies, simple backends |
| Watch out for | 6-connection limit on HTTP/1.1; text only | Proxy/firewall traversal; own framing | Header overhead per update |
The line is clean: if the client never needs to talk back, SSE is simpler than WebSocket in every way — no handshake, no frame parsing, free reconnects. And if you arrived here comparing SSE vs long polling, the last column is your answer: long polling is the legacy solution to the same problem, thousands of repeated requests where SSE holds one connection. WebSocket earns its complexity only when traffic flows both directions; our WebSocket explainer covers that lane in full. For a hands-on contrast, send a message through our WebSocket tester and then watch an SSE stream arrive — the difference between a conversation and a broadcast is obvious in seconds.
Where You'll Actually Meet SSE in 2026: AI Streaming
The direct answer: the reason SSE is everywhere in 2026 is token streaming. When a chatbot answers you word by word, the API is sending one SSE event per token — or per few tokens — and your app appends each data: payload to the screen.
The vendor docs say it plainly. Anthropic's "Streaming messages" documentation states that setting "stream": true streams the response using server-sent events, and shows the real event names: event: message_start, event: content_block_delta (carrying partial JSON text), event: message_stop, plus ping keep-alives dispersed through the stream. OpenAI's chat-completions reference documents the terminator every client watches for: the final chunk arrives as the line data: [DONE].
SSE's footprint goes beyond chat. SSE for AI streaming works so well because tokens are tiny ordered text events - exactly what the format was built for. The Model Context Protocol's original transport was SSE-based before the spec moved to Streamable HTTP — our Model Context Protocol guide covers that history. Dashboards, CI progress, live scores, and deploy logs all ride SSE for the same reason: the server knows things, the client just displays them.
5 SSE Production Gotchas
1. Proxy buffering silently batches your "live" stream
Reverse proxies and app servers love to buffer responses, which turns your realtime stream into bursts every few seconds. MDN's own PHP example sends X-Accel-Buffering: no for exactly this reason, and you must disable buffering at every layer — the app server, nginx, and any CDN in front. Run SSE behind containers and proxies deliberately; our Docker beginner guide is the lane to read before you deploy a streaming endpoint in Docker.
2. The HTTP/1.1 connection limit is real
MDN warns that over HTTP/1.1, browsers allow only 6 concurrent connections per browser per domain — and that limit is shared across all tabs. Open seven SSE streams and the seventh just waits. HTTP/2 fixes this by multiplexing (the limit is negotiated, defaulting to 100 streams), so serve SSE over HTTP/2 or HTTP/3 whenever you can.
3. EventSource cannot set custom headers
The constructor takes only a URL — no headers, no POST body. The standard workarounds are session cookies (with withCredentials: true) or a token in the query string, exactly as the wavepillars SSE guide recommends. If that is unacceptable, drop down to fetch() and parse the stream manually.
4. SSE is text-only, UTF-8 always
The WHATWG spec is blunt: event streams are always decoded as UTF-8, with no way to specify another encoding. Binary payloads must be Base64-encoded or carried some other way — SSE is a text protocol, full stop.
5. Every stream holds a socket you pay for
A long-lived connection is cheap until you have ten thousand of them. Each open stream holds a socket, a worker, or an async task on your server, so capacity planning for SSE is connection planning. A single VPS hosting box handles hundreds of streams comfortably; thousands is an architecture question (sticky sessions, connection draining), not a code question.
Sources
Every technical claim on this page was verified against these sources on 2026-11-04:
- WHATWG HTML Standard, §9.2 "Server-sent events" — the normative spec:
EventSourceinterface, field parsing, reconnect model,Last-Event-IDheader, HTTP 204 stop, UTF-8-only decoding. Fetched 2026-11-04. - MDN Web Docs, "Using server-sent events" — field reference (
event/data/id/retry), comment keep-alives, multi-dataconcatenation, auto-reconnect,close(), the HTTP/1.1 six-connection warning. Fetched 2026-11-04. - Anthropic, "Streaming messages" (Claude Platform Docs) —
"stream": truestreams via SSE; real event names (message_start,content_block_delta,message_stop,ping). Fetched 2026-11-04. - OpenAI API Reference, Chat completions — streamed chunks terminate with the
data: [DONE]line. Fetched 2026-11-04. - semitexa, "Server-Sent Events Explained: How SSE Works and When to Use It", dev.to — "reconnecting is not the same as recovering"; retained-events replay policy. Published ~37 days before this article; fetched via search 2026-11-04.
- wavepillars.com, "Server-Sent Events (SSE) and EventSource" — no custom headers on
EventSource; disable proxy buffering; heartbeat guidance; legacy MCP SSE transport note. Published ~116 days before this article; fetched via search 2026-11-04. - coinranking.com, "SSE vs WebSockets: Which Is Best for Real-Time Crypto Prices?" —
retry: 5000stream pattern;EventSourceauto-reconnect behavior. Updated 2026-11-02; fetched via search 2026-11-04. - algomaster.io, "Server-Sent Events (SSE) | System Design" —
Last-Event-IDreplay mechanics; replay needs durable server-side storage. Published ~39 days before this article; fetched via search 2026-11-04. - Author's live test, 2026-11-04 — a Python SSE server on this machine served a real
text/event-streamresponse; the response headers and event blocks quoted in this article are the verbatim bytes captured withcurl(430 bytes,wire-capture.txt).
Frequently Asked Questions
Is SSE better than WebSocket?
Neither is better in general — they solve different directions. SSE wins when data flows one way from server to client: it is plain HTTP, reconnects itself, and needs almost no client code. WebSocket wins when the client must talk back with low latency, like chat or games. If you are unsure, the decision table above settles it in one row.
Does SSE work over HTTP/2?
Yes, and you should prefer it there. The HTTP/1.1 limit of six concurrent connections per browser per domain disappears under HTTP/2, where the stream count is negotiated between client and server (default 100). SSE over HTTP/2 keeps the simple text format while removing the scaling headache.
Can the client send data back over an SSE connection?
No. SSE is strictly server-to-client — that is its defining property. The client sends data through a separate mechanism: an ordinary POST request, or a WebSocket if you need true bidirectional traffic.
How do I authenticate an SSE connection?
Since EventSource cannot set custom request headers, authenticate with session cookies (pass { withCredentials: true } for cross-origin) or put a token in the stream URL's query string. If your auth scheme demands headers, use fetch() with a manual stream parser instead.
Why does my SSE stream arrive in bursts instead of live?
Almost always proxy or app-server buffering. Each layer between your code and the browser — the app server, nginx, a CDN — may hold the response until its buffer fills. Disable buffering at every layer (X-Accel-Buffering: no is the classic nginx signal) and send periodic comment-line heartbeats to keep idle connections alive.

