Server-Sent Events

A one-way real-time communication protocol from server to client, over a single persistent HTTP connection.

HTTP Real-time Event streaming text/event-stream

The big idea

The client makes a single HTTP GET request. Instead of the server sending one response and closing the connection, it keeps the connection open and pushes plain-text events down the wire whenever it has something new to say.

That's the entire trick. No special protocol negotiation, no binary framing, no upgrade handshake. Just an HTTP response that never ends.

Direction
Server → Client only
Transport
Plain HTTP/1.1 or HTTP/2
Content type
text/event-stream
Browser API
EventSource

The wire format

Each event is a block of field: value lines, terminated by a blank line. There are only four possible fields:

id: 42
event: notification
data: {"user":"alice","msg":"hello"}
retry: 5000
↵ (blank line = end of event)
: this is a comment (keepalive)
data:
The event payload. Multiple data: lines are joined with newlines.
event:
Custom event type. Defaults to "message" if omitted.
id:
Sets the last event ID. Sent back on reconnection.
retry:
Reconnection delay in ms. Browser respects this.

Client-side API

The browser's EventSource API handles connection management, parsing, and reconnection. The entire client is a few lines:

const source = new EventSource('/events');

// Default "message" events
source.onmessage = (e) => {
  console.log(e.data);       // the payload string
  console.log(e.lastEventId); // the id: field
};

// Named events (event: notification)
source.addEventListener('notification', (e) => {
  console.log('Custom:', e.data);
});

// Connection lifecycle
source.onopen = () => console.log('Connected');
source.onerror = () => console.log('Reconnecting...');

// Explicitly close (no auto-reconnect)
source.close();
Tip: EventSource automatically reconnects on network failures. The browser sends a Last-Event-ID header with the last id: value it received, so the server can resume from where it left off.

Server-side (Node.js example)

const http = require('http');

http.createServer((req, res) => {
  if (req.url === '/events') {
    res.writeHead(200, {
      'Content-Type':  'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection':    'keep-alive',
    });

    let id = 0;
    const interval = setInterval(() => {
      id++;
      res.write(`id: ${id}\n`);
      res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
    }, 2000);

    // Clean up on disconnect
    req.on('close', () => clearInterval(interval));
  }
}).listen(3000);
Important: Set Cache-Control: no-cache to prevent proxies from buffering the stream. If you're behind Nginx, you'll also need X-Accel-Buffering: no.

Interactive simulator

Open a simulated SSE connection and watch the protocol in action. Send events, trigger disconnects, and observe auto-reconnection.

Client (browser)
Disconnected
Last event ID: none
Events received: 0
Server
Idle
Connection: none
Events sent: 0
HTTP connection (text/event-stream)
Event stream (raw bytes on the wire)
Click "Connect" to open an SSE connection and watch the protocol in action.

SSE vs alternatives

FeatureSSEWebSocketsPolling
DirectionServer → ClientBidirectionalClient → Server
ProtocolHTTPWS (upgrade)HTTP
Auto-reconnectBuilt inManualN/A
Event IDsBuilt inManualManual
Binary dataNo (text only)YesYes
ComplexityVery lowMediumLow
Best forLive feeds, notifications, streaming AIChat, gaming, collaborationInfrequent updates

Connection lifecycle

1. Opening

Client sends a GET with Accept: text/event-stream. Server responds 200 OK with Content-Type: text/event-stream and keeps the connection open.

2. Streaming

Server writes events as plain text, each terminated by a blank line. The browser parses each chunk and fires the appropriate event handler. Keepalive comments (: ping) prevent proxy timeouts.

3. Reconnection

If the connection drops, the browser waits (default ~3s, configurable via retry:) and automatically re-opens it. The Last-Event-ID header lets the server resume from the right point.

4. Closing

Calling source.close() terminates the connection permanently — no auto-reconnect. The server sees the TCP connection close and should clean up resources.

Common gotchas

HTTP/1.1 connection limit: Browsers allow only ~6 connections per domain on HTTP/1.1. Each SSE stream uses one. Use HTTP/2 (which multiplexes) or a dedicated subdomain for your event stream.
Proxy buffering: Many reverse proxies (Nginx, CloudFront) buffer responses by default, which delays event delivery. Set X-Accel-Buffering: no for Nginx, or use chunked transfer encoding.
Multi-line data: To send multi-line payloads, use multiple data: lines — the browser joins them with \n. Or just JSON-encode your payload in a single data: line.