← Back to the library
System designHow it works · Deep dive · 14 min

Server-Sent Events in production

How does an SSE stream really work end to end, and what breaks it between your server and the browser?

SSE is one long HTTP response with Content-Type text/event-stream, written as small text blocks the browser parses as events. The protocol is trivial; the work is in keeping every hop between your handler and the browser from buffering, compressing or timing out the stream, and in making reconnects resume instead of lose data.

THE MENTAL MODEL

Treat an SSE connection as a tail -f over HTTP. The server keeps a response open and appends blocks of field: value lines separated by blank lines; the browser's EventSource dispatches each block as an event, remembers the last id, and on any network drop reconnects by itself with that id in a Last-Event-ID header. That makes SSE a cursor-based subscription to a log: if your ids are positions in a durable log, a reconnect is a resume; if they are not, it is silent data loss. Everything else is plumbing: each layer (framework, reverse proxy, CDN, corporate proxy) must pass bytes through as they are written, idle timers must be fed with comment heartbeats, and HTTP/1.1's six-connections-per-origin limit means real deployments run SSE over HTTP/2 or HTTP/3.

HOW IT FITS TOGETHER

A stream that drops and resumes with Last-Event-IDResume only works because event ids are positions in a log the server can read from, not counters held in one process's memory.
  1. Browser (EventSource) → App serverGET /events Accept: text/event-streamCookies are sent; custom headers such as Authorization cannot be.
  2. App server → Browser (EventSource)200 text/event-stream, retry: 3000Headers are flushed immediately so EventSource fires open; retry sets the reconnect delay in ms.
  3. App server → Browser (EventSource)id: 41 event: order data: {...}
  4. App server → Browser (EventSource)id: 42 event: order data: {...}
  5. App server → Browser (EventSource): ping (comment, every 15 s)Ignored by the parser, but it resets idle timers in nginx, load balancers and the CDN.
  6. Proxy / CDN → Browser (EventSource)Connection lost (idle timeout, Wi-Fi)A network error, not an HTTP error, so EventSource goes to CONNECTING rather than CLOSED.
  7. Browser (EventSource) → Proxy / CDNAfter retry ms: GET Last-Event-ID: 42The browser adds the header itself. A reconnect may land on a different app instance.
  8. Proxy / CDN → App serverForward, Last-Event-ID: 42
  9. App server → Event logRead entries after 42Subscribe to live updates first and buffer them, so nothing published during the read is lost.
  10. Event log → App server43, 44
  11. App server → Browser (EventSource)id: 43, id: 44 replayed, then liveIf 42 is older than the log's retention, send event: reset and let the client refetch a snapshot.
Every hop that can hold your events backTop is your code, bottom is the browser. A single buffering layer anywhere turns a stream into one late response.
  1. HandlerWrites id/event/data blocks and flushes. Compression middleware is the usual in-process buffer: exclude text/event-stream or flush after each write.
  2. Runtime / frameworkNode writes through; Go needs an explicit Flush; a server WriteTimeout kills long streams; thread-per-request servers park a worker per client.
  3. Reverse proxy (nginx, ALB)nginx buffers proxied responses by default: proxy_buffering off or X-Accel-Buffering: no. proxy_read_timeout defaults to 60 s between reads.
  4. CDN / edge (Cloudflare)Cloudflare streams responses by default but returns 524 if the origin sends nothing for 125 s. Cache-Control: no-transform stops re-encoding.
  5. Corporate proxy, antivirusSome inspect whole responses before forwarding. You cannot fix them; detect no events after open and fall back to polling.
  6. Browser HTTP stackHTTP/1.1: about 6 connections per origin across all tabs. HTTP/2 and HTTP/3 multiplex streams (typically 100 concurrent).
  7. EventSource parserUTF-8 only, dispatches on a blank line, discards an unfinished block at end of stream, reconnects after network errors.
EventSource vs fetch-based SSESame wire format, different clients. LLM APIs use the fetch side because requests are POSTs with a body and an API key.
EventSourcefetch + ReadableStream
HTTP method and bodyGET only, no bodyAny method; POST with a JSON body
Request headersNone; cookies via withCredentialsAny, including Authorization
ReconnectAutomatic, honours retry:None; you write backoff and resume
Resume cursorLast-Event-ID sent automaticallyYou send the cursor yourself
ParsingBuilt in, spec-exactYours, or a library such as eventsource-parser
Cancelsource.close()AbortController.abort()
Status and errorsOpaque error event, no status codeFull Response: status, headers, error body

KEY TERMS

Wire format
UTF-8 text, one field per line as name: value, events separated by a blank line. data lines accumulate (joined with newlines), event sets the type (default message), id sets the last event id, retry sets the reconnect delay in milliseconds and is ignored unless it is all digits. Lines starting with a colon are comments. An id containing NULL is ignored; an empty id resets the cursor.
Reconnect and resume
After a network error EventSource waits the reconnection time and retries, sending Last-Event-ID if it has one. A non-200 status or a Content-Type other than text/event-stream fails the connection permanently (readyState CLOSED, no retry); servers use 204 to tell a client to stop. Resume semantics are yours: ids must map to positions in a log with known retention, plus a reset event for cursors that fell off the end.
Connection limits and HTTP versions
Over HTTP/1.1 browsers allow about six connections per origin, shared by every tab; Chrome and Firefox marked raising it as won't fix. Five open tabs with an EventSource each leave one connection for the whole site. HTTP/2 and HTTP/3 multiplex streams over one connection (the server advertises the limit, commonly 100), so terminate HTTP/2 at the edge; a SharedWorker can also share one stream across tabs.
Buffering, compression and timeouts
Anything that waits for more bytes before forwarding breaks streaming: nginx proxy buffering, gzip middleware, some PaaS edges. Turn buffering off per response with X-Accel-Buffering: no, exclude text/event-stream from compression, and send a comment heartbeat well inside the shortest idle timeout in the path (nginx 60 s read timeout, Cloudflare 125 s, ALB 60 s idle by default).
Authentication
EventSource cannot send Authorization headers, so same-origin cookies (HttpOnly, SameSite) are the clean option; cross-origin needs withCredentials plus CORS with an exact Access-Control-Allow-Origin and Allow-Credentials. Tokens in the query string end up in access logs, browser history and proxies: if you must, use a short-lived single-use ticket minted by an authenticated call. Recheck authorization on reconnect, and end the stream when the session is revoked.
Scaling and fan-out
Each client holds one response open on one instance, so state is per connection: memory, a file descriptor, a heartbeat timer. Instances subscribe to a shared pub/sub or log (Redis, NATS, Kafka, Postgres LISTEN/NOTIFY) and write matching events to their local connections. Because reconnects can land anywhere, replay must read from the shared log, never from instance memory.
LLM token streaming
The dominant modern use. The Claude Messages API with stream: true sends named events (message_start, content_block_delta, message_delta, message_stop, plus ping and error); OpenAI's Responses API sends typed events such as response.output_text.delta and response.completed. Both are POST requests, so browsers consume them with fetch, usually through your own backend so API keys stay server-side. Errors can arrive mid-stream as an event after a 200.

IN YOUR STACK

JavaScript · Node.js + Express Server

app.get("/events", async (req, res) => {
  res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no" });
  res.flushHeaders(); // EventSource fires "open" now, not at the first event
  res.write("retry: 3000\n\n");

  let last = Number(req.get("Last-Event-ID") ?? req.query.after ?? 0); // header on auto-reconnect, query on manual
  const send = (e) => {
    if (e.id <= last) return; // seen in replay already
    last = e.id;
    res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`);
  };
  // Subscribe before reading history, and buffer, so the gap between them loses nothing.
  let replaying = true;
  const pending = [];
  const unsubscribe = eventLog.subscribe((e) => (replaying ? pending.push(e) : send(e)));
  for (const e of await eventLog.readAfter(last)) send(e);
  replaying = false;
  pending.forEach(send);

  const ping = setInterval(() => res.write(": ping\n\n"), 15_000);
  req.on("close", () => { clearInterval(ping); unsubscribe(); });
});
  • eventLog is your shared log (Redis Stream, Kafka topic, an events table); subscribe and readAfter are its interface. In-memory history breaks as soon as a reconnect reaches another instance.
  • If you use the compression middleware, either skip text/event-stream in its filter or call res.flush() after each write; otherwise events sit in the gzip buffer.
  • JSON.stringify never emits raw newlines, so one data: line per event is safe. Multi-line text needs one data: line per line.
Node.js + Express documentation (opens in new tab)

TypeScript · Hono streamSSE Server

import { Hono } from "hono";
import { streamSSE } from "hono/streaming";

const app = new Hono();

// POST endpoint that relays model tokens; the browser reads it with fetch.
app.post("/chat", async (c) => {
  const { messages } = await c.req.json();
  return streamSSE(c, async (stream) => {
    const upstream = new AbortController();
    stream.onAbort(() => upstream.abort()); // user closed the tab: stop paying for tokens
    let seq = 0;
    try {
      for await (const token of generateTokens(messages, upstream.signal)) {
        await stream.writeSSE({ event: "token", id: String(++seq), data: JSON.stringify({ token }) });
      }
      await stream.writeSSE({ event: "done", data: "[DONE]" });
    } catch (err) {
      console.error(err);
      // Headers are already sent, so the failure must travel as an event.
      if (!upstream.signal.aborted) await stream.writeSSE({ event: "error", data: JSON.stringify({ message: "generation failed" }) });
    }
  });
});

export default app;
  • Runs unchanged on Workers, Deno, Bun and Node. generateTokens stands for your model call (an SDK stream or a fetch to the provider).
  • Once streaming starts, app.onError cannot change the response, so catch inside the callback. Avoid streamSSE's third (onError) argument here: after it runs, Hono also writes an error event containing err.message, which can leak internals to the client.
  • Propagating the abort upstream matters for cost: an LLM call keeps generating, and billing, after the client disconnects unless you cancel it.
Hono streamSSE documentation (opens in new tab)

Go · net/http ResponseController Server

func events(w http.ResponseWriter, r *http.Request) {
	rc := http.NewResponseController(w)
	_ = rc.SetWriteDeadline(time.Time{}) // lift Server.WriteTimeout for this stream only
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("X-Accel-Buffering", "no")
	sub := broker.SubscribeAfter(r.Header.Get("Last-Event-ID")) // replay, then live
	defer sub.Close()
	ping := time.NewTicker(15 * time.Second)
	defer ping.Stop()
	fmt.Fprint(w, "retry: 3000\n\n")
	for {
		if err := rc.Flush(); err != nil {
			return // write failed or this ResponseWriter cannot stream
		}
		select {
		case <-r.Context().Done(): // client went away
			return
		case e := <-sub.C:
			fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.ID, e.Type, e.JSON)
		case <-ping.C:
			fmt.Fprint(w, ": ping\n\n")
		}
	}
}
  • http.ResponseController (Go 1.20+) reaches Flush and per-request deadlines through middleware wrappers that hide http.Flusher.
  • Server.Shutdown waits for active requests and does not cancel their contexts, so long streams block it. Wire a shutdown context through BaseContext or close streams yourself on SIGTERM.
  • broker is your fan-out interface; keep its channel bounded and drop or disconnect slow clients instead of blocking the publisher.
net/http ResponseController documentation (opens in new tab)

Python · FastAPI 0.135+ fastapi.sse Server

from collections.abc import AsyncIterable
from typing import Annotated

from fastapi import FastAPI, Header
from fastapi.sse import EventSourceResponse, ServerSentEvent

app = FastAPI()

@app.get("/events", response_class=EventSourceResponse)
async def events(
    last_event_id: Annotated[int | None, Header()] = None,
) -> AsyncIterable[ServerSentEvent]:
    yield ServerSentEvent(comment="connected", retry=3000)
    # event_log.tail: replay entries after the cursor, then follow live ones.
    async for entry in event_log.tail(after=last_event_id or 0):
        yield ServerSentEvent(data=entry.payload, event=entry.type, id=str(entry.id))

@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat(prompt: Prompt) -> AsyncIterable[ServerSentEvent]:
    async for token in llm.stream(prompt.text):
        yield ServerSentEvent(data={"token": token}, event="token")
    yield ServerSentEvent(raw_data="[DONE]", event="done")
  • Native SSE arrived in FastAPI 0.135.0: it sets Cache-Control: no-cache and X-Accel-Buffering: no and sends a keep-alive ping comment after 15 s of silence. On older versions, sse-starlette's EventSourceResponse plays the same role.
  • data is JSON-encoded (even plain strings); use raw_data for pre-formatted text such as the [DONE] sentinel.
  • Starlette cancels the generator when the client disconnects; release subscriptions in a finally block.
FastAPI 0.135+ fastapi.sse documentation (opens in new tab)

JavaScript · Browser EventSource Client

let lastApplied = 0;
let source;

function connect() {
  source?.close(); // never run two streams (visibility and error handlers can both call this)
  // A new EventSource starts with an empty cursor, so pass ours explicitly.
  source = new EventSource(`/events?after=${lastApplied}`, { withCredentials: true });

  source.addEventListener("order", (e) => {
    const id = Number(e.lastEventId);
    if (id <= lastApplied) return; // duplicate after a replay
    lastApplied = id;
    render(JSON.parse(e.data));
  });
  source.addEventListener("reset", () => reloadSnapshot()); // cursor older than retention

  source.addEventListener("error", () => {
    if (source.readyState === EventSource.CONNECTING) return; // browser is retrying itself
    // CLOSED: non-200, wrong Content-Type or 204. The browser will not retry.
    source.close();
    setTimeout(connect, 2_000 + Math.random() * 8_000);
  });
}

connect();
document.addEventListener("visibilitychange", () => { if (document.hidden) source.close(); else connect(); });
  • On its own reconnects the browser sends Last-Event-ID; the after query parameter only covers the reconnects you start, so the server should accept either.
  • The error event carries no status code. Fetch the endpoint once with fetch() if you need to tell 401 from 503.
  • Closing hidden tabs frees HTTP/1.1 connection slots and server capacity; it is optional on HTTP/2.
Browser EventSource documentation (opens in new tab)

JavaScript · fetch + ReadableStream Client

async function* postSSE(url, body, { headers = {}, signal } = {}) {
  const res = await fetch(url, { method: "POST", signal, body: JSON.stringify(body),
    headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...headers } });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buf = "", end;
  for (;;) {
    const { value, done } = await reader.read();
    if (done) return;
    buf = (buf + value).replace(/\r\n|\r(?!$)/g, "\n"); // hold a trailing CR until its LF arrives
    while ((end = buf.indexOf("\n\n")) !== -1) {
      const block = buf.slice(0, end);
      buf = buf.slice(end + 2);
      const ev = { event: "message", id: undefined, data: [] };
      for (const line of block.split("\n")) {
        if (line.startsWith(":")) continue; // comment / heartbeat
        const i = line.indexOf(":"), field = i < 0 ? line : line.slice(0, i);
        const val = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
        if (field === "data") ev.data.push(val);
        else if (field === "event" || field === "id") ev[field] = val;
      }
      if (ev.data.length) yield { ...ev, data: ev.data.join("\n") };
    }
  }
}
  • Usage: for await (const e of postSSE("/chat", { messages }, { signal })) { if (e.event === "token") append(JSON.parse(e.data).token); }
  • No automatic reconnect: retry with backoff and send your own cursor. For LLM output, resuming usually means a new request, so persist partial text first.
  • A CR at the end of a chunk is held back so a CRLF split across reads is not counted as two line breaks. eventsource-parser also handles a BOM, retry and other edge cases, so prefer it in production.
fetch + ReadableStream documentation (opens in new tab)

nginx · ngx_http_proxy_module Server

server {
    listen 443 ssl;
    http2 on;                         # browsers multiplex SSE streams over one connection

    location /events {
        proxy_pass http://app_upstream;
        proxy_http_version 1.1;       # default only since nginx 1.29.7
        proxy_set_header Connection "";
        proxy_buffering off;          # or let the app send X-Accel-Buffering: no
        proxy_cache off;
        gzip off;                     # never compress-and-buffer the stream
        proxy_read_timeout 1h;        # default 60s between two reads from upstream
        proxy_send_timeout 1h;
    }
}
  • proxy_read_timeout applies between successive reads, not to the whole response, so a 15 s heartbeat also keeps the default 60 s from firing.
  • X-Accel-Buffering: no from the upstream disables buffering for that response unless proxy_ignore_headers lists it.
  • Without proxy_http_version 1.1 on older nginx and an empty Connection header, upstream keepalive is lost and every stream opens a new upstream connection.
ngx_http_proxy_module documentation (opens in new tab)

WHEN TO REACH FOR WHAT

SSE with EventSource

Use when Server-to-client feeds over GET: notifications, dashboards, job progress, live comments. You want reconnect and resume for free and plain HTTP auth, caching rules and proxies.

Avoid when The client also sends frequent low-latency messages, you need binary payloads, or auth must travel in a header from a browser.

SSE over fetch (POST streams)

Use when One request produces one streamed answer: LLM tokens, report generation, long-running queries. You need a request body and an Authorization header.

Avoid when You want a long-lived subscription with automatic resume; you would be rebuilding EventSource by hand.

WebSockets

Use when Both directions are chatty and latency-sensitive: chat with typing, collaborative editing, multiplayer, trading. Binary frames or a custom protocol help.

Avoid when Traffic is one-way. You give up HTTP semantics, built-in resume and simple proxying for capability you will not use.

Long or short polling

Use when Updates every few seconds are fine, a proxy you do not control buffers streams, or you need a fallback that works everywhere.

Avoid when Many clients need sub-second freshness; the request rate and empty responses dominate cost.

WHERE IT BITES

  • Testing locally, then deploying behind nginx or a PaaS edge that buffers: events arrive all at once when the stream ends. Check with curl -N through the real edge, and set X-Accel-Buffering: no and exclude text/event-stream from compression.
  • Serving SSE over HTTP/1.1: a user with a handful of tabs open exhausts the per-origin connection pool and the whole site stops loading. Terminate HTTP/2 or HTTP/3 at the edge, or share one stream across tabs with a SharedWorker or BroadcastChannel leader.
  • Using in-process counters as event ids. After a deploy or a reconnect to another instance, Last-Event-ID points at nothing and events are silently skipped. Ids must be positions in a shared log, with a reset event for cursors past retention.
  • Returning 401 or 500 to a stream and expecting a retry: any non-200 closes EventSource for good. Use that deliberately (204 to stop, 401 to force re-login) and handle readyState CLOSED in the client.
  • Leaving the upstream work running after the client disconnects. Listen for close/abort and cancel subscriptions, database cursors and especially LLM calls, which keep consuming tokens.
  • Putting bearer tokens in the EventSource URL. They land in access logs, CDN logs and browser history. Prefer HttpOnly cookies, or a single-use ticket that expires in seconds.

CLOSE THE AI. EXPLAIN THIS.

Your SSE feed works on localhost but in production events arrive in bursts every minute or so and some disappear after reconnects. Walk the stack from handler to EventSource, name the two most likely buffering layers and the setting that fixes each, then explain what has to be true about event ids for Last-Event-ID to make reconnects lossless.

SOURCES

Explainer reviewed