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
- Browser (EventSource) → App serverGET /events Accept: text/event-streamCookies are sent; custom headers such as Authorization cannot be.
- App server → Browser (EventSource)200 text/event-stream, retry: 3000Headers are flushed immediately so EventSource fires open; retry sets the reconnect delay in ms.
- App server → Browser (EventSource)id: 41 event: order data: {...}
- App server → Browser (EventSource)id: 42 event: order data: {...}
- 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.
- 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.
- 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.
- Proxy / CDN → App serverForward, Last-Event-ID: 42
- App server → Event logRead entries after 42Subscribe to live updates first and buffer them, so nothing published during the read is lost.
- Event log → App server43, 44
- 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.
- HandlerWrites id/event/data blocks and flushes. Compression middleware is the usual in-process buffer: exclude text/event-stream or flush after each write.
- Runtime / frameworkNode writes through; Go needs an explicit Flush; a server WriteTimeout kills long streams; thread-per-request servers park a worker per client.
- 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.
- 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.
- Corporate proxy, antivirusSome inspect whole responses before forwarding. You cannot fix them; detect no events after open and fall back to polling.
- Browser HTTP stackHTTP/1.1: about 6 connections per origin across all tabs. HTTP/2 and HTTP/3 multiplex streams (typically 100 concurrent).
- EventSource parserUTF-8 only, dispatches on a blank line, discards an unfinished block at end of stream, reconnects after network errors.
| EventSource | fetch + ReadableStream | |
|---|---|---|
| HTTP method and body | GET only, no body | Any method; POST with a JSON body |
| Request headers | None; cookies via withCredentials | Any, including Authorization |
| Reconnect | Automatic, honours retry: | None; you write backoff and resume |
| Resume cursor | Last-Event-ID sent automatically | You send the cursor yourself |
| Parsing | Built in, spec-exact | Yours, or a library such as eventsource-parser |
| Cancel | source.close() | AbortController.abort() |
| Status and errors | Opaque error event, no status code | Full 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.
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.
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.
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.
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.
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.
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.
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.WHEN IT BREAKS IN PRODUCTION
SOURCES
- HTML Standard: Server-sent events (opens in new tab)Checked
- MDN: EventSource (opens in new tab)Checked
- NGINX: ngx_http_proxy_module (opens in new tab)Checked
- Cloudflare: Connection limits (opens in new tab)Checked
- Claude API: Streaming messages (opens in new tab)Checked
- OpenAI: Streaming API responses (opens in new tab)Checked
Explainer reviewed