How Server-Sent Events (SSE) Work: HTTP Streaming for Real-Time Updates
Server-Sent Events (SSE) is a standard for pushing real-time updates from a server to a client over a single, long-lived HTTP connection. Unlike WebSockets, which provide full-duplex bidirectional communication, SSE is unidirectional: the server sends events to the client, and the client cannot send data back over the same connection. This constraint is also its strength — SSE works over plain HTTP, requires no protocol upgrade, passes through every proxy and firewall that supports HTTP, and provides built-in reconnection and event replay that WebSockets lack entirely. SSE was standardized as part of HTML5 and is defined in the WHATWG HTML Living Standard (previously W3C).
The text/event-stream Format
An SSE connection is a normal HTTP response with Content-Type: text/event-stream. The response body is a stream of UTF-8 text, never closed by the server (unless the stream ends). Events are separated by two newline characters (\n\n), and each event consists of one or more fields, each on its own line in field: value format.
The specification defines four field types:
data: This is a simple message
\n
data: This message has
data: multiple lines
\n
event: bgp-update
data: {"prefix":"1.1.1.0/24","origin":13335,"type":"announcement"}
\n
id: 1714003200
event: bgp-withdrawal
data: {"prefix":"203.0.113.0/24","origin":64496}
\n
retry: 5000
\n
data:— the event payload. Multipledata:lines are concatenated with newlines. This is the only required field.event:— a named event type. Without this field, the event fires the genericmessageevent on the EventSource. With it, the event fires a named event listener (e.g.,bgp-update,bgp-withdrawal).id:— an event identifier used for reconnection. The client stores the last received ID and sends it asLast-Event-IDon reconnection.retry:— reconnection interval in milliseconds. The server can control how long the client waits before reconnecting after a dropped connection.
Lines starting with a colon (:) are comments and are ignored. Servers commonly send comment-only heartbeats to keep the connection alive through intermediaries that might close idle connections:
: keepalive
\n
The simplicity of this format is deliberate. There is no binary framing, no length prefixes, no complex encoding. The entire protocol can be debugged by reading raw HTTP responses with curl:
$ curl -N -H "Accept: text/event-stream" https://example.com/events
: connected
retry: 3000
event: bgp-update
id: 42
data: {"prefix":"1.1.1.0/24","asPath":[13335],"collector":"rrc00"}
event: bgp-update
id: 43
data: {"prefix":"8.8.8.0/24","asPath":[15169],"collector":"rrc01"}
The EventSource API
On the client side, the browser provides the EventSource API — a high-level interface that handles connection management, reconnection, and event dispatching. Creating an SSE connection is trivial:
const source = new EventSource('/events');
// Generic message handler (events without an "event:" field)
source.onmessage = (e) => {
console.log('Message:', e.data);
};
// Named event handlers
source.addEventListener('bgp-update', (e) => {
const update = JSON.parse(e.data);
console.log(`${update.prefix} via AS${update.origin}`);
});
source.addEventListener('bgp-withdrawal', (e) => {
const withdrawal = JSON.parse(e.data);
console.log(`Withdrawn: ${withdrawal.prefix}`);
});
// Connection lifecycle
source.onopen = () => console.log('Connected');
source.onerror = (e) => {
if (source.readyState === EventSource.CONNECTING) {
console.log('Reconnecting...');
} else {
console.log('Connection failed');
}
};
The EventSource object manages three states, exposed via readyState:
CONNECTING(0) — the connection is being established or the client is reconnecting after a failureOPEN(1) — the connection is active and receiving eventsCLOSED(2) — the connection has been closed (either by callingsource.close()or by the server sending a non-200 or non-event-stream response)
A key limitation of EventSource is that it only supports GET requests with no custom headers beyond cookies. You cannot send a POST body, set an Authorization header, or include custom headers. For APIs requiring token-based authentication, this means either passing the token as a query parameter (visible in server logs and URL history) or using cookies. Alternatively, libraries like eventsource (Node.js) or fetch-based SSE implementations provide more control at the cost of reimplementing reconnection logic.
Automatic Reconnection and Event Replay
SSE's built-in reconnection mechanism is one of its strongest features and a significant advantage over raw WebSockets. When the connection drops — due to a network interruption, server restart, or load balancer timeout — the EventSource automatically reconnects after a configurable delay.
The reconnection flow works as follows:
- The server sends events with
id:fields. The client stores the most recently received ID. - When the connection drops, the client waits for the retry interval (default 3 seconds, configurable via the
retry:field). - The client reconnects with a
Last-Event-IDHTTP header containing the last received ID. - The server reads
Last-Event-IDand replays any events that occurred after that ID.
This mechanism provides at-least-once delivery semantics, assuming the server can replay events from a given ID. The server must maintain an event log or be able to reconstruct missed events from its data store. For a BGP update feed, this might mean querying route changes with a timestamp greater than the last event's timestamp.
// Server-side (Node.js / Express example)
app.get('/bgp-stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const lastId = req.headers['last-event-id'];
// Replay missed events if reconnecting
if (lastId) {
const missedUpdates = db.getUpdatesSince(parseInt(lastId));
for (const update of missedUpdates) {
res.write(`id: ${update.id}\n`);
res.write(`event: bgp-update\n`);
res.write(`data: ${JSON.stringify(update)}\n\n`);
}
}
// Set retry interval to 5 seconds
res.write('retry: 5000\n\n');
// Stream live updates
const listener = (update) => {
res.write(`id: ${update.id}\n`);
res.write(`event: bgp-update\n`);
res.write(`data: ${JSON.stringify(update)}\n\n`);
};
bgpFeed.on('update', listener);
req.on('close', () => {
bgpFeed.off('update', listener);
});
});
The retry: field gives the server control over reconnection backoff. A server under load can send retry: 30000 to tell clients to wait 30 seconds before reconnecting, providing a natural form of backpressure. The server can also send an HTTP 204 No Content response on reconnection to signal that the client should stop reconnecting entirely — any non-200 response code causes the EventSource to enter the CLOSED state without further reconnection attempts.
Connection Management and Scaling
Each SSE connection is a long-lived HTTP response. On the server, this means one open connection per client. For servers built on event-loop architectures (Node.js, Rust/Tokio, Go goroutines), holding thousands of idle connections is inexpensive — each connection consumes a file descriptor and a small amount of memory, but no thread or process is blocked. For thread-per-connection servers (traditional Java Servlet, PHP-FPM), SSE can exhaust the thread pool quickly.
Connection management considerations:
- Heartbeats — Send periodic comment lines (
:\n\n) to detect dead connections and prevent intermediaries from closing idle connections. Many load balancers and reverse proxies default to 60-second idle timeouts. A 15-30 second heartbeat interval keeps connections alive. - Connection limits — Browsers limit the number of concurrent HTTP/1.1 connections to the same origin (typically 6). Since each SSE stream consumes one connection, a page with multiple SSE streams can exhaust the limit, blocking other HTTP requests. HTTP/2 multiplexing solves this by allowing unlimited concurrent streams over a single TCP connection.
- Load balancer configuration — Reverse proxies need appropriate timeout settings for SSE. Nginx requires
proxy_buffering offand a longproxy_read_timeout. HAProxy requirestimeout tunnelsettings. Without these, the proxy buffers the response (delaying events) or terminates the connection prematurely. - Horizontal scaling — When running multiple server instances behind a load balancer, SSE connections are sticky by nature (each client connects to one instance). Broadcasting events to all connected clients requires a pub/sub backend (Redis, NATS, Kafka) so that all instances receive events regardless of which instance produced them.
SSE over HTTP/2 and HTTP/3
SSE was designed for HTTP/1.1, where each connection occupies a dedicated TCP connection. HTTP/2 significantly improves SSE by multiplexing multiple streams over a single TCP connection. This eliminates the browser's 6-connection-per-origin limit and reduces resource overhead on both client and server.
With HTTP/2, a page can maintain multiple SSE streams (e.g., a BGP updates stream, a status stream, and a notifications stream) alongside normal API requests, all sharing a single TCP connection. Each SSE stream is an independent HTTP/2 stream with its own flow control, so a high-volume event stream does not block other requests.
HTTP/3 (QUIC) further improves SSE by eliminating head-of-line blocking at the transport layer. With HTTP/2 over TCP, a single dropped packet stalls all multiplexed streams. HTTP/3's QUIC transport handles each stream independently, so packet loss on one SSE stream does not affect other streams or concurrent API requests.
SSE vs WebSockets vs Long Polling
The choice between SSE, WebSockets, and long polling depends on the communication pattern, infrastructure constraints, and complexity budget:
| Feature | SSE | WebSockets | Long Polling |
|---|---|---|---|
| Direction | Server → Client only | Bidirectional | Server → Client (simulated) |
| Protocol | HTTP (no upgrade) | WebSocket (HTTP upgrade) | HTTP |
| Data format | UTF-8 text only | Text and binary | Any HTTP body |
| Reconnection | Built-in (automatic) | Manual (application code) | Built-in (next poll) |
| Event replay | Built-in (Last-Event-ID) | Manual (application code) | Manual (application code) |
| Browser support | All modern (no IE) | All modern | All browsers |
| Proxy compatibility | Excellent (plain HTTP) | Mixed (some proxies block upgrades) | Excellent |
| HTTP/2 multiplexing | Yes (native) | No (separate TCP connection) | Yes (but high overhead) |
| Server complexity | Low | Medium | Low |
| Latency | Low (streaming) | Lowest (streaming) | Higher (per-request overhead) |
| Custom headers | No (EventSource limitation) | No (WebSocket limitation) | Yes (standard HTTP) |
Use SSE when: data flows from server to client only (notifications, feeds, dashboards, progress updates), you want automatic reconnection without application code, your infrastructure includes proxies that may not support WebSocket upgrades, or you are already using HTTP/2 and want to benefit from multiplexing.
Use WebSockets when: you need bidirectional communication (chat, collaborative editing, gaming), you need to send binary data (audio, video, protocol buffers), or the client needs to send frequent messages to the server without the overhead of separate HTTP requests.
Use long polling when: you need maximum browser compatibility (including legacy browsers), the event rate is low (long polling is inefficient for high-frequency updates), or you need custom HTTP headers on every request.
Event Types and Routing
Named event types provide a lightweight pub/sub mechanism within a single SSE connection. Instead of multiplexing different data channels over separate connections or using a single message event with a type discriminator in the payload, the event: field routes events to different handlers at the protocol level:
// Server sends different event types on the same stream
event: route-announcement
data: {"prefix":"1.1.1.0/24","asn":13335,"path":[13335]}
event: route-withdrawal
data: {"prefix":"203.0.113.0/24"}
event: peer-state-change
data: {"peer":"198.51.100.1","state":"up","asn":64496}
event: collector-status
data: {"collector":"rrc00","status":"active","updates_per_sec":142}
// Client registers typed handlers
const source = new EventSource('/bgp-stream');
source.addEventListener('route-announcement', (e) => {
const ann = JSON.parse(e.data);
routeTable.addRoute(ann.prefix, ann.asn, ann.path);
});
source.addEventListener('route-withdrawal', (e) => {
const wd = JSON.parse(e.data);
routeTable.removeRoute(wd.prefix);
});
source.addEventListener('peer-state-change', (e) => {
const peer = JSON.parse(e.data);
peerPanel.updateStatus(peer.peer, peer.state);
});
source.addEventListener('collector-status', (e) => {
const status = JSON.parse(e.data);
statusBar.update(status.collector, status);
});
This pattern is clean but has a limitation: the client cannot selectively subscribe to event types. The server sends all event types on the stream, and the client simply ignores events for which it has no handler. For high-volume streams where the client only cares about a subset of events, this wastes bandwidth. The alternative is to add server-side filtering via query parameters (/bgp-stream?events=route-announcement,peer-state-change) or to use separate SSE endpoints per event type.
Error Handling and Edge Cases
SSE error handling differs from WebSockets in important ways. The EventSource API fires a generic error event with no useful information about what went wrong — no error code, no error message, no distinction between network errors, server errors, or intentional disconnection. This is a deliberate security decision (preventing cross-origin information leakage), but it makes debugging difficult.
The reconnection behavior has specific rules:
- Network errors and connection drops — EventSource reconnects automatically after the retry interval.
- HTTP 200 with wrong Content-Type — EventSource fires an error and enters CLOSED state. No reconnection.
- HTTP 204 No Content — EventSource enters CLOSED state. No reconnection. Servers use this to deliberately stop a client from reconnecting.
- HTTP 301/307 redirects — EventSource follows the redirect transparently.
- HTTP 401/403 — EventSource enters CLOSED state. No reconnection. This is problematic for token-based auth where the fix is to refresh the token and reconnect — the application must detect the closed state and create a new EventSource.
- CORS errors — EventSource fires an error and enters CLOSED state. Cross-origin SSE requires appropriate
Access-Control-Allow-Originheaders, includingAccess-Control-Allow-Credentials: trueif using cookies.
For applications that need more control over reconnection behavior (exponential backoff, jitter, token refresh on 401), the fetch API with ReadableStream provides a lower-level alternative to EventSource:
async function connectSSE(url, handlers) {
let retryMs = 1000;
let lastEventId = null;
while (true) {
try {
const headers = {
'Accept': 'text/event-stream',
'Authorization': `Bearer ${getToken()}`
};
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
const response = await fetch(url, { headers });
if (response.status === 204) return; // Server says stop
if (response.status === 401) {
await refreshToken();
continue;
}
retryMs = 1000; // Reset backoff on successful connection
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse events from buffer...
}
} catch (err) {
retryMs = Math.min(retryMs * 2, 30000); // Exponential backoff
await new Promise(r => setTimeout(r, retryMs));
}
}
}
SSE for AI and LLM Streaming
Server-Sent Events have seen a resurgence as the standard transport for streaming responses from Large Language Model APIs. When an LLM generates text token by token, SSE provides a natural fit: the server streams each token as an event, and the client renders them incrementally. OpenAI's API, Anthropic's API, and most other LLM providers use SSE for streaming completions.
The typical format follows the OpenAI convention:
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"delta":{"content":"The"},"index":0}]}
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"delta":{"content":" BGP"},"index":0}]}
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"delta":{"content":" protocol"},"index":0}]}
data: [DONE]
This use case highlights SSE's strengths: unidirectional streaming (the prompt was sent in the initial POST), text-only data (JSON tokens), and easy parsing in any language. It also exposes a limitation: the standard EventSource API cannot send the initial POST request with the prompt in the body, so LLM clients use fetch with streaming response parsing instead.
Server Implementation Patterns
Implementing an SSE server requires attention to buffering, connection tracking, and graceful shutdown:
Buffering is the most common pitfall. HTTP servers, reverse proxies, and frameworks often buffer responses for efficiency. For SSE, buffering delays event delivery — events pile up in a buffer and are flushed together instead of being sent immediately. The solution varies by stack:
- Node.js:
res.flush()orres.flushHeaders() - Python/ASGI: use
StreamingResponse(Starlette/FastAPI) orStreamingHttpResponse(Django) - Rust/Axum: use
Sseresponse type withtokio::sync::broadcast - Nginx:
proxy_buffering off; X-Accel-Buffering: no; - Cloudflare: responses with
Transfer-Encoding: chunkedand noContent-Lengthare streamed automatically
Connection tracking is needed for broadcasting. The server maintains a set of connected clients and fans out events to all of them. In Rust with Tokio, a broadcast channel provides an efficient multi-producer, multi-consumer pattern. In Node.js, maintaining a Set<Response> and iterating over it for each event is the common approach. For multi-process or multi-node deployments, a message broker (Redis Pub/Sub, NATS) distributes events to all server instances.
Graceful shutdown should close SSE connections cleanly. When the server is shutting down, it should close all SSE responses, causing clients to detect the disconnection and reconnect (to another instance if behind a load balancer). The server should not accept new SSE connections during the drain period.
Security Considerations
SSE inherits HTTP's security model, which simplifies some concerns but introduces others:
- Authentication —
EventSourcesends cookies automatically, making cookie-based auth straightforward. Token-based auth (Bearer tokens) requires workarounds since EventSource does not support custom headers. Common approaches: pass the token in the URL (leaks to logs), use a short-lived auth cookie set by a prior API call, or use a fetch-based SSE implementation. - Cross-origin — SSE respects CORS. Cross-origin EventSource requests require
Access-Control-Allow-Origin. ThewithCredentialsoption enables cookies for cross-origin SSE. - Data exposure — SSE data is UTF-8 text in an HTTP response, visible to any intermediary that can inspect HTTP traffic. Use TLS (HTTPS) to encrypt the stream.
- Resource exhaustion — Since each client holds a connection open, SSE endpoints are targets for connection exhaustion attacks. Rate-limit connection creation and enforce maximum connection duration.
Real-World Use Cases
SSE is well-suited for several categories of real-time applications:
- Live feeds — Stock tickers, news feeds, sports scores, BGP update streams. The data flows one way, events are independent, and reconnection with replay ensures no updates are missed.
- Progress and status updates — Build pipelines, file uploads, long-running computations. The client starts a job via POST, then opens an SSE connection to receive progress events.
- Notifications — In-app notifications, alerts, system status changes. Low-frequency events where the simplicity of SSE outweighs the overhead of a WebSocket connection.
- Log streaming — Tailing logs in a web interface. Each log line is an event, and
Last-Event-IDenables resuming from where the stream was interrupted. - LLM token streaming — As described above, SSE has become the standard for streaming AI-generated text from server to client.
Summary
Server-Sent Events provide a simple, robust mechanism for server-to-client real-time streaming over standard HTTP. The text/event-stream format is human-readable and trivially parseable. The EventSource API handles reconnection and event replay automatically, with Last-Event-ID providing at-least-once delivery semantics that WebSockets must implement manually. Named event types enable lightweight event routing without application-level dispatching. HTTP/2 multiplexing eliminates the connection-limit problem that plagues SSE under HTTP/1.1, and HTTP/3 removes head-of-line blocking at the transport layer.
The tradeoff is clear: SSE sacrifices bidirectional communication and binary data support for simplicity, automatic reconnection, and seamless integration with HTTP infrastructure. For the many applications where data flows primarily from server to client — feeds, notifications, progress updates, LLM streaming — SSE is the right tool. For interactive, bidirectional communication, WebSockets remain necessary.
To see real-time network data in action, try the god.ad BGP Looking Glass — it ingests live BGP updates from RIPE RIS collectors to provide up-to-the-second routing information for any IP address or autonomous system number.