SSE Protocol Fundamentals & Architecture Permalink to this section
Server-Sent Events (SSE) is a standardized, unidirectional streaming protocol that pushes a stream of UTF-8 text frames from server to client over a single, long-lived HTTP response. It is defined by the WHATWG HTML “Server-sent events” specification, runs unchanged over HTTP/1.1 and HTTP/2, and needs no protocol upgrade, no custom framing, and no client library — the browser’s built-in EventSource handles parsing, dispatch, and automatic reconnection. This page is the protocol-layer reference for engineers shipping telemetry dashboards, notification feeds, LLM token streams, and live activity panes: the exact wire format, the request/response header contract, the connection lifecycle, the reverse-proxy and load-balancer topologies that keep streams alive, the failure modes that silently kill them in production, and the migration and fallback paths when EventSource is not enough. Get these fundamentals right and SSE is the cheapest, most operable real-time transport you can deploy; get them wrong and streams stall behind buffering proxies, leak file descriptors, and replay duplicate events on every reconnect.
How SSE works at the protocol level Permalink to this section
An SSE session is a single ordinary HTTP GET whose response never ends until one side closes the socket. The client sends Accept: text/event-stream; the server replies 200 OK with Content-Type: text/event-stream, keeps the response body open, and writes one event “block” at a time. Each block is a set of field: value lines terminated by a blank line — that is, a \n\n sequence is the dispatch trigger. The browser’s EventSource reads the byte stream incrementally, splits it on line terminators, accumulates field values, and fires a DOM event when it hits the blank line.
The contract is deliberately minimal. There are exactly four recognized fields — data, event, id, and retry — plus comment lines that begin with a colon. Anything the parser does not recognize is ignored, which is what makes the format forward-compatible. A complete, runnable Node server is just a handler that writes the right headers and never returns:
// Minimal compliant SSE endpoint on raw Node http
import http from 'node:http';
http.createServer((req, res) => {
if (req.url !== '/events') { res.writeHead(404).end(); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8', // mandatory MIME type
'Cache-Control': 'no-cache, no-transform', // no-transform stops gzip/proxy mangling
'Connection': 'keep-alive', // HTTP/1.1: hold the socket open
'X-Accel-Buffering': 'no', // tell nginx not to buffer this response
});
let id = 0;
// Comment line as an immediate flush + a no-op for the client
res.write(': connected\n\n');
const tick = setInterval(() => {
id += 1;
res.write(`id: ${id}\n`); // sets Last-Event-ID on the client
res.write('event: tick\n'); // dispatches a "tick" listener, not onmessage
res.write(`data: ${Date.now()}\n\n`); // blank line => dispatch
}, 1000);
const heartbeat = setInterval(() => res.write(': hb\n\n'), 15000); // keep idle proxies open
req.on('close', () => { clearInterval(tick); clearInterval(heartbeat); }); // client gone
}).listen(8080);
On the client, three lines wire it up. The EventSource opens the connection, applies any server-sent retry interval on disconnect, and re-sends the last id it saw via the Last-Event-ID request header so the server can resume:
const es = new EventSource('/events');
es.addEventListener('tick', (e) => console.log('server time', e.data));
es.onerror = () => console.log('readyState', es.readyState); // 0 CONNECTING, 1 OPEN, 2 CLOSED
Because the body is plain chunked text, you can verify any endpoint with curl -N (the -N disables curl’s own buffering) and read the raw frames. The line-by-line parsing rules — including how multiple data: lines are joined with \n and how a leading space after the colon is stripped — are covered in depth in Understanding the Event Stream Format, and the exact MIME-type handling in How to Parse the text/event-stream MIME Type Correctly.
Wire format and HTTP header contract Permalink to this section
The wire format is line-oriented and case-sensitive. Field names are lowercase, the separator is the first colon on the line, and the byte that ends a block is a blank line. The table below is the complete recognized field set; everything else is ignored or treated as a comment.
| Field | Purpose | Client effect | Notes |
|---|---|---|---|
data |
Payload | Appended to the event’s data buffer; multiple data: lines are joined with \n |
The only field whose value reaches your onmessage/listener as event.data |
event |
Event type name | Dispatches to addEventListener('<name>', …) instead of onmessage |
Defaults to message when omitted |
id |
Last-Event-ID | Stored; replayed as the Last-Event-ID header on the next reconnect |
An id with a NUL byte (\0) is ignored per spec |
retry |
Reconnection delay (ms) | Sets the auto-reconnect interval | Integer only; non-numeric values are ignored |
:comment |
Comment / heartbeat | None — ignored by the parser | Used to keep connections and proxies warm |
The HTTP layer has its own contract. These headers are not optional in production; each one defends against a specific intermediary behavior.
| Header (direction) | Required value | Why it matters |
|---|---|---|
Content-Type (response) |
text/event-stream |
EventSource rejects any other type; the connection errors out |
Cache-Control (response) |
no-cache, no-transform |
Prevents CDNs/proxies from caching or gzip-rewriting the stream |
Connection (response, HTTP/1.1) |
keep-alive |
Keeps the TCP socket open for the long-lived body |
X-Accel-Buffering (response) |
no |
Disables nginx response buffering per-response |
Accept (request) |
text/event-stream |
Sent automatically by EventSource; validate it server-side |
Last-Event-ID (request) |
last seen id |
Sent automatically on reconnect; drives resume logic |
Two constraints trip teams up repeatedly. First, the payload is UTF-8 text only — there is no binary frame type, so raw bytes must be base64-encoded or moved to a side channel. Second, a literal newline inside a data value will end the value early and corrupt the block, so multi-line payloads must be split into one data: line per source line; see Formatting Multi-Line data Fields in SSE. The id and retry semantics are the backbone of recovery and are designed in detail in Event ID & Retry Mechanism Design and tuned in Setting the retry Interval in SSE Streams.
Connection lifecycle and resume Permalink to this section
A stream moves through a small, well-defined state machine: CONNECTING (0) → OPEN (1) → on network failure back to CONNECTING after the retry delay → CLOSED (2) only when you call close() or the server returns a non-2xx, non-text/event-stream response. Crucially, EventSource reconnects on its own; you do not write reconnection code unless you need custom backoff or headers. What you do own is making reconnection correct.
Correct resume hinges on the id field. Every time the server emits an id, the browser remembers it. On the next reconnect the browser sends Last-Event-ID: <that value> as a request header, and your handler should replay everything after that point:
// Resume from the client's last-seen id on (re)connect
function handler(req, res) {
const lastId = Number(req.headers['last-event-id'] ?? 0);
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
// Replay buffered events the client missed, then go live
for (const ev of eventLog.since(lastId)) {
res.write(`id: ${ev.id}\nevent: ${ev.type}\ndata: ${JSON.stringify(ev.body)}\n\n`);
}
subscribe(res); // attach to the live feed
}
This only works if IDs are strictly increasing and the server retains enough history to cover the gap. Make event processing idempotent so a reconnect storm that replays an overlapping window does not double-apply state — see Generating Monotonic Event IDs for SSE. Heartbeats matter just as much: a stream that goes silent for 60 seconds looks dead to most proxies, so emit a : hb\n\n comment every 15–30 seconds to keep intermediaries from reaping the socket.
Architecture patterns and proxy configuration Permalink to this section
SSE’s biggest operational hazard is an intermediary that buffers the response, holding frames until its buffer fills or the request “completes” — which never happens for a stream. The fix is to disable buffering at every hop and to raise idle timeouts above your heartbeat interval.
For nginx, three directives are non-negotiable, plus an HTTP/1.1 upgrade so the upstream connection stays persistent:
location /events {
proxy_pass http://sse_upstream;
proxy_http_version 1.1; # 1.0 closes the upstream after each response
proxy_set_header Connection ""; # clear the hop-by-hop Connection header
proxy_buffering off; # stream chunks straight through
proxy_cache off; # never cache an event stream
proxy_read_timeout 1h; # must exceed heartbeat interval by a wide margin
chunked_transfer_encoding on;
}
For a load balancer terminating long-lived connections, the controlling knobs are the client and server idle timeouts. HAProxy needs them lifted well past the default 50 seconds, and option http-keep-alive set:
defaults
timeout connect 5s
timeout client 1h # client-side idle: long-lived stream
timeout server 1h # upstream idle: matches the app's keep-alive
option http-keep-alive
Envoy, AWS ALB, and GCP load balancers each have an equivalent idle-timeout setting; the rule is the same everywhere — the proxy idle timeout must be larger than the application heartbeat interval, or the stream dies on a timer. Backend-side, the connection and lifecycle tuning that keeps these sockets healthy lives in HTTP Keep-Alive & Connection Lifecycle, and the broader backend implementation patterns across Node, FastAPI, and Go are collected under Backend Stream Generation & Connection Management.
The third topology is fan-out. A single node can hold tens of thousands of streams, but an event that originates anywhere must reach every node holding a relevant subscriber. The standard answer is a pub/sub bus: each node subscribes to a channel, publishes domain events to it, and writes received messages to its local set of open responses. That decouples event production from connection ownership and lets you scale nodes independently. The implementation is covered in Broadcasting SSE Events with Redis Pub/Sub and Scaling SSE Across Multiple Nodes with Redis.
Edge cases and failure modes Permalink to this section
Most SSE incidents are not protocol bugs — they are intermediary or lifecycle bugs that hide until load arrives. Work through these systematically.
- Proxy buffering swallows frames. Symptom: events arrive in bursts or only when the connection closes. Cause: a CDN, nginx, or sidecar buffering the response. Mitigation: set
proxy_buffering off, emitX-Accel-Buffering: nofrom the app, and sendCache-Control: no-transformto stop gzip coalescing. - Idle timeout kills the stream on a timer. Symptom: clients reconnect every 30–60 seconds like clockwork. Cause: a proxy or LB idle timeout below your heartbeat interval. Mitigation: raise
proxy_read_timeout/timeout client/timeout serverand keep heartbeats at 15–30s. - Multi-line or unescaped payloads corrupt blocks. Symptom: truncated JSON or a
datavalue that ends early. Cause: a literal\nin a value, or a missing trailing blank line. Mitigation: split on\ninto multipledata:lines and always end the block with\n\n. - Duplicate events after reconnect. Symptom: the same item applied twice. Cause: overlapping replay from
Last-Event-IDplus non-idempotent handlers. Mitigation: dedupe byid, make writes idempotent, keep IDs monotonic. - HTTP/1.1 six-connection limit per origin. Symptom: a seventh SSE tab (or concurrent stream) hangs. Cause: browsers cap parallel HTTP/1.1 connections per origin at ~6, and an open SSE stream consumes one slot. Mitigation: serve over HTTP/2 (much higher concurrency via multiplexing) or share a single stream across tabs via a
BroadcastChannel/SharedWorker. - CORS rejects the stream. Symptom:
EventSourceerrors immediately cross-origin. Cause: missingAccess-Control-Allow-Origin, or credentials withoutAccess-Control-Allow-Credentials. Mitigation: see Handling CORS in SSE Implementations. - Slow consumers apply backpressure. Symptom: server memory climbs as write buffers fill for clients that read slowly. Cause: unbounded per-connection queues. Mitigation: bound queues and drop or coalesce — see Handling Slow Consumers with SSE Backpressure.
Horizontal scaling and production ops Permalink to this section
SSE scales out cleanly because each stream is a single ordinary HTTP connection — but each connection holds an open file descriptor and a slice of memory for its write buffer for as long as it lives. Capacity planning is therefore a function of FD limits, memory per connection, and how you fan events across nodes.
Raise OS limits before you hit them. A node serving 50,000 streams needs its nofile soft limit well above that, plus headroom for upstream sockets and logs:
# Per-process file descriptor ceiling (systemd unit or /etc/security/limits.conf)
ulimit -n 200000
# Expand the ephemeral port range and reuse TIME_WAIT sockets on busy nodes
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sysctl -w net.ipv4.tcp_tw_reuse=1
The detailed methodology — measuring per-connection cost, setting limits, and validating headroom — is in Tuning File-Descriptor Limits for SSE Connection Pools and Configuring Connection Pools for High-Concurrency SSE.
Deployment is the second hard problem: rolling out new code closes every stream on a drained node, triggering a synchronized reconnect that can stampede the survivors. Drain connections gracefully and stagger reconnects with jittered retry values so the herd spreads over seconds, not milliseconds. For Go services, the channel-and-flusher pattern and clean teardown are in Implementing SSE with Go Channels and http.Flusher and Graceful Shutdown for Go SSE Servers.
Observability for SSE differs from request/response services because the unit of work lives for minutes or hours. Instrument the lifecycle, not just the request:
| Signal | What to record | Alert threshold (example) |
|---|---|---|
| Active connections | Gauge per node | > 80% of FD ceiling |
| Connection age | Histogram of stream duration | Sudden drop in p50 = mass reconnect |
| Reconnect rate | Reconnects/sec across the fleet | Spike = node failure or timeout misconfig |
| Heartbeat write errors | Failed write() count |
Any sustained nonzero = dead sockets accumulating |
| Fan-out lag | Bus publish → client write latency | p99 > 1s |
Track active-connection gauges per node and reconnect rate across the fleet; a cliff in connection age combined with a reconnect spike is the fingerprint of an intermediary timeout regression. Authentication and origin security for these long-lived sessions are covered in Security Headers for Event Streams and Authenticating SSE Streams with Tokens & Cookies.
Migration and fallback paths Permalink to this section
Most SSE adoptions replace a polling loop. The migration is low-risk because SSE is still HTTP: keep the polling endpoint, add a streaming one, and switch clients behind a feature flag. Where SSE wins or loses against alternatives — and when to reach for WebSockets instead — is laid out in SSE vs WebSockets vs HTTP Polling and the decision framework in When to Use Server-Sent Events over WebSockets.
The one real limitation of native EventSource is that it only issues a GET and cannot set arbitrary request headers (notably Authorization). When you need custom headers, POST bodies, or finer control over reconnection, drop to fetch + ReadableStream and parse the wire format yourself. The format is identical, so the server does not change:
// fetch + ReadableStream fallback: works where EventSource can't send headers
async function streamWithAuth(url, token, onEvent, signal) {
const res = await fetch(url, {
headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}` },
signal,
});
if (!res.ok || !res.body) throw new Error(`stream init failed: ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep;
while ((sep = buffer.indexOf('\n\n')) !== -1) { // split complete blocks
const block = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const ev = { event: 'message', data: [] };
for (const line of block.split('\n')) {
if (line.startsWith(':')) continue; // comment/heartbeat
const i = line.indexOf(':');
const field = i === -1 ? line : line.slice(0, i);
const val = i === -1 ? '' : line.slice(i + 1).replace(/^ /, '');
if (field === 'data') ev.data.push(val);
else if (field === 'event') ev.event = val;
else if (field === 'id') ev.id = val;
}
if (ev.data.length) onEvent({ ...ev, data: ev.data.join('\n') });
}
}
}
Note that with fetch you also inherit responsibility for reconnection and Last-Event-ID resend that EventSource gave you for free. For truly legacy targets, feature-detect with 'EventSource' in window and load a polyfill — strategy in Browser Support & Polyfill Strategies and Cross-Browser Compatibility for the EventSource API. On the consumption side, the framework patterns for both EventSource and fetch streams are in Frontend Consumption & Client Patterns.
⚡ Production Directives
- Set
Content-Type: text/event-streamplusCache-Control: no-cache, no-transformon every stream response, and disable buffering at every proxy hop. - Keep proxy/LB idle timeouts strictly greater than your heartbeat interval (heartbeat every 15–30s, timeout ≥ 1h).
- Emit strictly monotonic
idvalues, honorLast-Event-IDon reconnect, and make all event handlers idempotent. - Raise
nofilewell above peak concurrent streams and alert at 80% of the FD ceiling. - Drain connections and use jittered
retryon deploy so reconnects spread out instead of stampeding.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Is SSE limited to HTTP/1.1's six-connection-per-origin cap?
Over HTTP/1.1 browsers allow roughly six concurrent connections per origin, and each open stream consumes one. Over HTTP/2 the streams are multiplexed on one TCP connection, so the practical limit is far higher (typically 100 concurrent streams). Serve SSE over HTTP/2, or share one stream across tabs with a SharedWorker, to avoid the cap.
Can SSE carry binary data?
No. The wire format is UTF-8 text only and has no binary frame type. Base64-encode binary payloads inside a data field, or move bytes to a separate channel. WebSockets are the better fit when binary throughput dominates.
Do I need to write reconnection logic with EventSource?
No — EventSource reconnects automatically, applying any server-sent retry interval and resending the last id via the Last-Event-ID header. You only write custom reconnection when you need jittered backoff, custom request headers, or a POST body, in which case you switch to fetch + ReadableStream.
Why do my events arrive in bursts instead of in real time?
An intermediary is buffering the response. Disable proxy buffering (proxy_buffering off, X-Accel-Buffering: no) and send Cache-Control: no-transform so gzip or a CDN does not coalesce chunks.
How do I authenticate an SSE stream when EventSource can't set headers?
Either authenticate with a cookie (sent automatically with withCredentials and proper CORS), pass a short-lived token as a query parameter, or use fetch + ReadableStream to send an Authorization header. See the authentication guide for token rotation and cookie scoping details.