Area: Streaming from server to browser
Related: Browser APIs (map), WebSocket, Intl
What is it?
Server-Sent Events (SSE) is a way for a server to push a stream of messages to the browser over one ordinary HTTP response that just doesn’t end. The server keeps writing small text chunks, and the browser hands each one to your code as it arrives.
A WebSocket is a phone call. SSE is a radio broadcast: the server talks, the browser listens.
It’s the standard way to stream AI answers token by token, and it’s good for notifications, progress bars, logs and live scores.
The wire format
The response has Content-Type: text/event-stream. The body is plain text, made of events separated by a blank line:
data: Hello
data: first line
data: second line
event: progress
id: 42
data: {"percent": 40}
: this line is a comment, often used as a heartbeat
retry: 5000
Field by field:
data:the message. Severaldata:lines in one event are joined with\n.event:a name. Without it, the event type ismessage.id:the event’s id. The browser remembers the last one it saw.retry:how many milliseconds to wait before reconnecting.- A line starting with
:is a comment and is ignored. Servers send: pingevery so often to keep proxies from closing an idle connection. - A blank line ends the event. Until the blank line comes, nothing is delivered.
That’s the whole format. You can write it with any server, no library needed.
The browser API: EventSource
const source = new EventSource("/api/notifications");
source.onmessage = (event) => console.log(event.data); // unnamed events
source.addEventListener("progress", (e) => update(JSON.parse(e.data))); // named ones
source.onerror = () => console.log("lost connection, retrying...");
source.close(); // stop for goodWhat EventSource does for you:
- Reconnects automatically when the connection drops, after the
retrydelay. - When it reconnects, it sends
Last-Event-IDwith the last id it saw. A server that keeps recent events can resend only what was missed. That’s built-in resume, something you have to build yourself with WebSockets. - Parses the format.
What EventSource can’t do
This is why many apps don’t use it:
- GET only. You can’t POST a body. An AI chat request with a long prompt and history doesn’t fit in a URL.
- No custom headers. No
Authorization: Bearer. Only cookies (withCredentials: truefor cross-origin). - You can’t see the HTTP status when it fails. A 401 and a network blip look the same.
Streaming with fetch instead
So for AI streaming and anything authenticated, people use fetch and read the body as a stream, then parse the SSE format themselves:
const response = await fetch("/api/ask", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ question }),
signal: abortController.signal, // lets the user press "Stop"
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Events end with a blank line. Keep any half event in the buffer.
const parts = buffer.split("\n\n");
buffer = parts.pop()!;
for (const part of parts) {
const data = part
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, ""))
.join("\n");
if (data) handleEvent(data);
}
}Two details here trip people up.
1. A network chunk is not an event. reader.read() gives you whatever bytes arrived. One chunk might hold three events, or half of one. That’s why the code keeps a buffer and only processes text up to the last blank line. The rest waits for the next chunk.
2. A chunk can split a character in half. In UTF-8, an emoji or a Korean character is several bytes. If a chunk ends in the middle of one, decoding each chunk separately gives you garbage (�). decoder.decode(value, { stream: true }) tells the decoder to hold back an incomplete character until the next chunk completes it.
Also handle \r\n line endings, since the spec allows them, and flush the buffer at the end in case the last event had no trailing blank line.
You lose auto-reconnect and Last-Event-ID this way. For a one-shot AI answer that’s fine: if it breaks, show an error and let the user retry.
Things that break streaming in production
SSE works perfectly on localhost and then arrives all at once in production. The usual causes:
- Buffering proxies. Nginx, some CDNs and some load balancers collect the whole response before forwarding it. Fixes: send
X-Accel-Buffering: nofor Nginx, and turn off buffering in the proxy settings. - Compression. gzip middleware can wait for a big enough block before sending. Disable compression for
text/event-stream, or make sure it flushes. - Framework response buffering. Make sure your server flushes after each event. In Node, write to the response and don’t end it.
- Idle timeouts. A proxy may cut a connection that sends nothing for 30 to 120 seconds. Send a
: pingcomment every 15 to 30 seconds. - The 6-connection limit. Over HTTP/1.1, browsers allow only 6 connections per domain. Each open
EventSourceuses one, and they’re shared across tabs. Six tabs with an SSE stream each, and the seventh tab can’t load anything from that domain. HTTP/2 fixes this, because many streams share one connection.
Headers worth sending:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
SSE vs WebSocket
| SSE | WebSocket | |
|---|---|---|
| Direction | server → client | both ways |
| Protocol | plain HTTP | its own protocol after an upgrade |
| Data | text only (send JSON or base64) | text and binary |
| Reconnect and resume | built into EventSource | build it yourself |
| Auth | cookies (EventSource) or anything (fetch) | cookies, URL token, or first message |
| Works through proxies | usually, it’s just HTTP | sometimes blocked |
| Server scaling | ordinary HTTP servers | stateful, sticky routing |
Rule of thumb: if the client only needs to listen, use SSE. If both sides send often, use a WebSocket.
A real-world example
An “ask your notes” AI assistant streams its answers with fetch plus a small hand-written SSE parser, not EventSource, because the request is a POST with a body and an auth header. The parser keeps a buffer, splits on blank lines, joins data: lines, and has a flush() for a final event with no trailing blank line. Each event carries a delta (the new piece of text), and the client appends it to what’s already on screen.
Gotchas
EventSourceis GET-only with no custom headers. Usefetchstreaming when you need POST or a bearer token.- One network chunk ≠ one event. Buffer until the blank line.
- Decode with
{ stream: true }, or multi-byte characters break at chunk edges. - Fine on localhost and batched in prod? It’s a proxy or compression buffering.
- Send heartbeat comments so idle proxies don’t cut the stream.
- HTTP/1.1 caps you at 6 connections per domain, across all tabs.
- Give the user a Stop button. Wire it to an
AbortController.
Practice
- Write a Node endpoint that sends the time every second as SSE. Read it with
EventSource. Kill the server and watch it reconnect. - Add
id:fields andLast-Event-IDhandling so a reconnect resumes where it left off. - Rewrite the client with
fetchand a stream reader. Test with a message containing emoji, split across chunks on purpose. - Put Nginx in front with default settings and watch the stream arrive all at once. Fix it with
X-Accel-Buffering: no.
Sources worth reading
- MDN: Using server-sent events, EventSource
- HTML spec: Server-sent events (the parsing rules section is short and exact)
- MDN: Using readable streams, TextDecoder.decode() (the
streamoption)