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. Several data: lines in one event are joined with \n.
  • event: a name. Without it, the event type is message.
  • 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 : ping every 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 good

What EventSource does for you:

  • Reconnects automatically when the connection drops, after the retry delay.
  • When it reconnects, it sends Last-Event-ID with 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: true for 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: no for 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 : ping comment every 15 to 30 seconds.
  • The 6-connection limit. Over HTTP/1.1, browsers allow only 6 connections per domain. Each open EventSource uses 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

SSEWebSocket
Directionserver → clientboth ways
Protocolplain HTTPits own protocol after an upgrade
Datatext only (send JSON or base64)text and binary
Reconnect and resumebuilt into EventSourcebuild it yourself
Authcookies (EventSource) or anything (fetch)cookies, URL token, or first message
Works through proxiesusually, it’s just HTTPsometimes blocked
Server scalingordinary HTTP serversstateful, 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

  • EventSource is GET-only with no custom headers. Use fetch streaming 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

  1. Write a Node endpoint that sends the time every second as SSE. Read it with EventSource. Kill the server and watch it reconnect.
  2. Add id: fields and Last-Event-ID handling so a reconnect resumes where it left off.
  3. Rewrite the client with fetch and a stream reader. Test with a message containing emoji, split across chunks on purpose.
  4. 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 stream option)