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

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"}
Server-Sent Events: Connection Lifecycle Client Server GET /events Accept: text/event-stream 200 OK Content-Type: text/event-stream event: bgp-update id: 42 event: bgp-update id: 43 Connection dropped (network error) wait retry ms GET /events Last-Event-ID: 43 200 OK (resume from event 44) event: bgp-update id: 44 1 2 3 4 5 6

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:

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:

  1. The server sends events with id: fields. The client stores the most recently received ID.
  2. When the connection drops, the client waits for the retry interval (default 3 seconds, configurable via the retry: field).
  3. The client reconnects with a Last-Event-ID HTTP header containing the last received ID.
  4. The server reads Last-Event-ID and 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:

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 Connection Models: HTTP/1.1 vs HTTP/2 HTTP/1.1 Browser TCP conn 1: SSE /bgp-updates TCP conn 2: SSE /notifications TCP conn 3: SSE /status TCP conn 4-6: API requests (only 3 left!) 6 conn limit per origin HTTP/2 Browser Single TCP Connection Stream 1: SSE /bgp-updates Stream 3: SSE /notifications Stream 5: SSE /status Streams 7,9,...: API requests (unlimited) No connection limit

SSE vs WebSockets vs Long Polling

The choice between SSE, WebSockets, and long polling depends on the communication pattern, infrastructure constraints, and complexity budget:

FeatureSSEWebSocketsLong Polling
DirectionServer → Client onlyBidirectionalServer → Client (simulated)
ProtocolHTTP (no upgrade)WebSocket (HTTP upgrade)HTTP
Data formatUTF-8 text onlyText and binaryAny HTTP body
ReconnectionBuilt-in (automatic)Manual (application code)Built-in (next poll)
Event replayBuilt-in (Last-Event-ID)Manual (application code)Manual (application code)
Browser supportAll modern (no IE)All modernAll browsers
Proxy compatibilityExcellent (plain HTTP)Mixed (some proxies block upgrades)Excellent
HTTP/2 multiplexingYes (native)No (separate TCP connection)Yes (but high overhead)
Server complexityLowMediumLow
LatencyLow (streaming)Lowest (streaming)Higher (per-request overhead)
Custom headersNo (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:

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:

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:

Real-World Use Cases

SSE is well-suited for several categories of real-time applications:

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.

See BGP routing data in real time

Open Looking Glass
More Articles
How API Gateways Work: Routing, Auth, Rate Limiting, and Protocol Translation
How GraphQL Works: Schema, Queries, Resolvers, and Execution
How HTTP Caching Works: Cache-Control, ETags, and CDN Edge Caching
How HTTP Cookies Work: Set-Cookie, Security Attributes, and SameSite
What is BGP? The Internet's Routing Protocol Explained
What is an Autonomous System (AS)?