Real-time Updates — Server-Sent Events
One open HTTP response, a stream of named text events, and browser-native reconnection — without WebSockets.
Polling keeps reopening the conversation just to ask whether anything changed. Server-Sent Events flips that model: make one HTTP request, leave its response open, and let the server write text events whenever it has something new. It is server push without introducing a WebSocket protocol.
One request, one response, many events
Short polling pays for a full request cycle on every interval. Long polling waits longer, but it still eventually returns and starts over. SSE keeps a single response alive. The first request is client-initiated; after that, fresh data flows server → client whenever it is ready.
The handshake is ordinary HTTP. The important signal is the response header Content-Type: text/event-stream. It tells the browser to expect a UTF-8 event stream instead of one complete JSON document.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"price":48.12}
data: {"price":48.19}
| Polling | Server-Sent Events | |
|---|---|---|
| Connection | Repeated request/response cycles | One long-lived HTTP response |
| Who sets the cadence? | The client interval or retry loop | The server writes when data exists |
| Empty work | Short polls can return no update | No event is sent until there is something to say |
| Recovery | Application-owned retry and cursor logic | Native EventSource retry + Last-Event-ID |
| Direction | Client asks, server answers | Server → client after setup |
Four fields and one blank line
SSE framing is deliberately small: lines of text grouped into events. A blank line dispatches the complete event. The browser understands four field names; unknown fields are ignored. Explore each one:
Named events multiplex one connection
Without an event field, the browser dispatches the payload as a normal message. Add a name and one stream can route several update types without opening one connection per feature.
event: message-created
data: {"id":"m-81","text":"Hello"}
event: user-typing
data: {"userId":"u-4"}
: keep-alive
event: user-stopped-typing
data: {"userId":"u-4"}
: is ignored by the event parser, but its bytes keep the connection active through intermediaries that would otherwise close an idle stream.EventSource is convenient — fetch is flexible
Native EventSource gives the browser enough information to parse events, dispatch named types, and reconnect automatically. Its API is tiny:
const source = new EventSource("/events");
source.onmessage = (event) => {
render(JSON.parse(event.data));
};
source.addEventListener("message-created", (event) => {
appendMessage(JSON.parse(event.data));
});
source.onerror = () => showConnectionState("reconnecting");
// Stop this subscription deliberately:
source.close();The trade-off is control: native EventSource makes a GET request and does not let you attach arbitrary request headers or a POST body. Cookies work well for same-origin auth; short-lived URL tokens are possible but easy to leak. A fetch-based SSE client is the common choice when the request needs an authorization header, method, or JSON body.
const response = await fetch("/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
},
body: JSON.stringify({ messages }),
});
const reader = response.body?.getReader();
// Decode bytes, buffer incomplete blocks, then parse complete SSE events.Reconnect is built in; replay is not
If a native EventSource stream drops, the browser waits and reconnects. When events include id, it also sends the most recently seen value back as Last-Event-ID. That is enough for the server to know where the client stopped — but the server still needs retained history and replay logic.
Treat the event ID like a cursor, not a delivery guarantee. Reconnect boundaries can still produce duplicates, and a client may disappear before acknowledging what it rendered. Make event handling idempotent or deduplicate by ID when double-processing would hurt.
Five sharp edges to design around
Recognize buffering by its shape
Your server can call write() correctly while nginx, a CDN, compression middleware, or another intermediary collects those writes. The format survives, but the experience stops being incremental.
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.write("event: delta\ndata: " + JSON.stringify(payload) + "\n\n");Why AI responses stream so naturally over SSE
A model produces output incrementally. The user sends one prompt, then mostly listens while the server produces text deltas. Waiting for the entire answer hides progress and inflates perceived latency; streaming lets the first useful words arrive immediately.
The fit is unusually clean: client-initiated request, server-heavy response, text payloads, ordinary HTTP infrastructure, and an explicit completion event. The pieces delivered by the transport are not guaranteed to equal words or model tokens; the UI should simply append deltas in order.
When SSE is the right fit
Pick it when…
- Updates flow primarily from server to client.
- The payload is text: notifications, counters, logs, progress, prices, feeds, or AI deltas.
- You want low-latency push and native EventSource reconnection.
- Staying on normal HTTP infrastructure is operationally valuable.
- Your backend can hold many connections without blocking one thread per client.
Choose something else when…
- The client and server both send frequently on the same long-lived channel — consider WebSockets.
- You need binary audio, video, or a custom binary protocol — SSE is UTF-8 text.
- The required backend or proxy path cannot sustain or flush long-lived responses.
- Updates are rare and can be minutes stale — simple polling may still be cheaper to operate.
Check your understanding
Design challenge
Design a deployment-status page that receives log lines, progress, a final result, and occasional errors. Write down:
- The named event types and the shape of each
datapayload. - An event-ID strategy and how long the server retains replay history.
- Whether the browser uses EventSource or fetch, based on auth and request-body needs.
- The headers and proxy settings required for incremental delivery.
- What the UI does with duplicates, a reconnect, and an unrecoverable gap.
- →SSE is one long-lived HTTP response; the server writes events instead of the client repeatedly polling.
- →The format is UTF-8 text with
data,event,id, andretryfields; a blank line dispatches an event. - →Native
EventSourceparses, dispatches, and reconnects, but GET/header limits often push production apps toward fetch-based clients. - →
Last-Event-IDsupplies a resume cursor; reliable replay remains a server-side application contract. - →Plan for auth, connection limits, proxy buffering, backend concurrency, and SSE's one-way/text-only boundary.
- →AI streaming is a strong fit because one client command produces an incremental, server-driven text response.