Server-Sent Events (SSE) is a standard HTTP-based protocol that lets a server push real-time updates to the browser over a single, long-lived connection. Unlike WebSockets, SSE uses plain HTTP — it works through every proxy, CDN, and load balancer without special configuration. The browser EventSource API handles reconnection automatically, resumes from the last received event via Last-Event-ID, and has 97.23% global browser support (Can I Use, 2026). SSE is the protocol behind AI streaming responses (ChatGPT, Claude), live dashboards, notification feeds, and any scenario where the server needs to push data and the client only needs to listen.
How Do Server-Sent Events Work?
The client opens an HTTP connection with Accept: text/event-stream. The server responds with Content-Type: text/event-stream and holds the connection open, writing events as UTF-8 text. Each event is a set of field lines separated by a blank line.
Browser Server
│ │
│ GET /events │
│ Accept: text/event-stream │
│ ────────────────────────────────────────► │
│ │
│ 200 OK │
│ Content-Type: text/event-stream │
│ Cache-Control: no-cache │
│ Connection: keep-alive │
│ ◄──────────────────────────────────────── │
│ │
│ data: {"price": 142.50}\n\n │
│ ◄──────────────────────────────────────── │
│ │
│ data: {"price": 143.10}\n\n │
│ ◄──────────────────────────────────────── │
│ │
× Connection drops │
│ │
│ GET /events │
│ Last-Event-ID: 42 │
│ ────────────────────────────────────────► │
│ (auto-reconnect after 3s) │What Does the Wire Format Look Like?
The SSE wire format is defined by the WHATWG HTML spec. Messages are plain text with four field types:
| Field | Purpose | Example |
|---|---|---|
| data: | Event payload (multiple lines concatenated with \n) | data: {"status": "ok"} |
| event: | Named event type (defaults to "message") | event: price-update |
| id: | Event ID for resumption via Last-Event-ID | id: 42 |
| retry: | Reconnection delay in milliseconds | retry: 5000 |
| : comment | Ignored by client — used for keep-alive pings | : heartbeat |
Messages are terminated by a blank line (\n\n). A complete event looks like:
id: 42
event: price-update
data: {"symbol": "AAPL", "price": 142.50}
id: 43
event: price-update
data: {"symbol": "AAPL", "price": 143.10}
: keep-alive comment (ignored by client)How to Use EventSource in the Browser
The EventSource API is built into every modern browser. It handles connection management, automatic reconnection, and event parsing.
// Connect to SSE endpoint
const source = new EventSource('/api/events');
// Listen for default "message" events
source.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
// Listen for named events
source.addEventListener('price-update', (event) => {
const { symbol, price } = JSON.parse(event.data);
updateTicker(symbol, price);
});
// Connection opened
source.onopen = () => {
console.log('Connected, readyState:', source.readyState);
// readyState: 0 = CONNECTING, 1 = OPEN, 2 = CLOSED
};
// Handle errors (browser auto-reconnects unless you close)
source.onerror = (err) => {
if (source.readyState === EventSource.CLOSED) {
console.log('Connection was closed by server');
} else {
console.log('Connection lost, reconnecting...');
}
};
// Clean up when done
source.close();For cross-origin SSE with cookies, pass { withCredentials: true } as the second argument to EventSource.
How to Build an SSE Server in Node.js (TypeScript)
An SSE server is just an HTTP endpoint that writes text/event-stream formatted data. No special libraries required.
Raw Node.js HTTP server
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
interface SSEClient {
id: string;
res: ServerResponse;
}
const clients = new Map<string, SSEClient>();
function sendEvent(res: ServerResponse, event: string, data: unknown, id?: string) {
if (id) res.write(`id: ${id}\n`);
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function handleSSE(req: IncomingMessage, res: ServerResponse) {
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // disable nginx/reverse proxy buffering
});
// Set retry interval to 5 seconds
res.write('retry: 5000\n\n');
const clientId = crypto.randomUUID();
clients.set(clientId, { id: clientId, res });
// Resume from last event if reconnecting
const lastId = req.headers['last-event-id'];
if (lastId) {
console.log(`Client ${clientId} reconnecting from event ${lastId}`);
// Replay missed events from your event store here
}
// Keep-alive ping every 15 seconds
const keepAlive = setInterval(() => res.write(': ping\n\n'), 15_000);
// Clean up on disconnect
req.on('close', () => {
clearInterval(keepAlive);
clients.delete(clientId);
console.log(`Client ${clientId} disconnected (${clients.size} remaining)`);
});
}
// Broadcast to all connected clients
function broadcast(event: string, data: unknown) {
const id = Date.now().toString();
for (const client of clients.values()) {
sendEvent(client.res, event, data, id);
}
}
const server = createServer((req, res) => {
if (req.url === '/events') return handleSSE(req, res);
res.writeHead(404).end();
});
server.listen(3000, () => console.log('SSE server on :3000'));
// Example: broadcast a timestamp every 2 seconds
setInterval(() => broadcast('tick', { time: new Date().toISOString() }), 2000);Express + TypeScript
import express, { type Request, type Response } from 'express';
const app = express();
app.get('/events', (req: Request, res: Response) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// Send an initial event
res.write(`data: ${JSON.stringify({ connected: true })}\n\n`);
// Stream data
const interval = setInterval(() => {
res.write(`id: ${Date.now()}\n`);
res.write(`event: update\n`);
res.write(`data: ${JSON.stringify({ ts: Date.now() })}\n\n`);
}, 1000);
req.on('close', () => clearInterval(interval));
});
app.listen(3000);Web-standard Response (Hono / Cloudflare Workers)
function sseHandler(): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
let id = 0;
const interval = setInterval(() => {
const event = [
`id: ${++id}`,
'event: update',
`data: ${JSON.stringify({ time: new Date().toISOString() })}`,
'', // blank line terminates the event
'',
].join('\n');
controller.enqueue(encoder.encode(event));
}, 1000);
// Clean up if the client disconnects
// (AbortSignal handling depends on runtime)
setTimeout(() => {
clearInterval(interval);
controller.close();
}, 60_000);
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}SSE vs WebSockets: When to Use Which?
SSE and WebSockets solve different problems. SSE is unidirectional (server → client) over standard HTTP. WebSockets are bidirectional over a separate ws:// protocol that requires an HTTP upgrade handshake. Choose based on your data flow — not because one is "better."
| Factor | SSE | WebSocket |
|---|---|---|
| Direction | Server → client only | Bidirectional |
| Protocol | Standard HTTP/1.1 or HTTP/2 | ws:// / wss:// (HTTP upgrade) |
| Auto-reconnect | Built-in (browser handles it) | Must implement manually |
| Resume from last event | Built-in (Last-Event-ID header) | Must implement manually |
| Data format | UTF-8 text only | Text and binary (ArrayBuffer, Blob) |
| Per-message overhead | ~100-200 bytes (HTTP text fields) | 2-14 bytes (binary frame header) |
| HTTP/2 multiplexing | Yes — shares connection with other requests | No — separate TCP connection |
| Proxy/CDN/firewall | Works natively (plain HTTP) | May require special configuration |
| Connection limit (HTTP/1.1) | 6 per domain (across all tabs) | No browser-imposed limit |
| Connection limit (HTTP/2) | ~100 streams (negotiable) | No browser-imposed limit |
| Browser support | 97.23% (no IE) | 98.4% (including IE 10+) |
| CORS | Standard HTTP CORS | Origin header only (no preflight) |
Use SSE when
- The server pushes data and the client only listens (notifications, live feeds, AI streaming, dashboards)
- You want automatic reconnection and event resumption without writing any code
- You need to work through corporate proxies, CDNs, or load balancers without reconfiguration
- You're on HTTP/2 and want to multiplex the event stream with other requests on the same connection
Use WebSockets when
- Both client and server need to send data (chat, collaborative editing, multiplayer games)
- You need to send binary data (file transfers, audio/video, game state)
- Per-message overhead matters at high volume (WebSocket frames are 2-14 bytes vs SSE's ~100+ bytes of text)
- You need more than 6 concurrent connections per domain on HTTP/1.1
Auto-Reconnection and Event Resumption
One of SSE's biggest advantages over WebSockets is built-in reliability. The browser handles reconnection automatically — you don't write retry loops.
Default retry delay
Browser waits 3 seconds before reconnecting. Server can override with retry: field.
retry: 5000Last-Event-ID
On reconnect, browser sends the last received event ID as a header. Server can replay missed events.
Last-Event-ID: 42Server opt-out
Responding with HTTP 204 tells the browser to stop reconnecting permanently.
HTTP/1.1 204 No ContentManual close
Calling source.close() sets readyState to CLOSED and prevents any reconnection.
source.close()Connection Limits and HTTP/2
On HTTP/1.1, browsers enforce a 6 connection limit per domain — this includes SSE connections. Opening SSE in multiple tabs can exhaust the limit, blocking other requests. This is marked "Won't Fix" in both Chrome and Firefox.
HTTP/2 solves this by multiplexing up to ~100 streams over a single TCP connection. Each SSE stream is just one multiplexed stream, leaving plenty of capacity for other requests. If you're deploying SSE in production, use HTTP/2.
Common Patterns and Best Practices
Keep-alive pings
Proxies and load balancers often kill idle connections after 30-60 seconds. Send comment lines as heartbeats:
// Server-side: send a comment every 15 seconds
const keepAlive = setInterval(() => {
res.write(': ping\n\n');
}, 15_000);Disable buffering
Reverse proxies buffer responses by default, preventing events from reaching the client in real time. Disable it. If your stream is cross-origin, double-check your CORS configuration too — preflights interacting with proxy buffering are a classic SSE debugging trap.
| Proxy | How to disable buffering |
|---|---|
| Nginx | X-Accel-Buffering: no header or proxy_buffering off; |
| Apache | No buffering by default for text/event-stream |
| Cloudflare | Automatically disabled for text/event-stream |
| AWS ALB/NLB | No buffering for streaming responses (ensure idle timeout > stream duration) |
Graceful shutdown
// Track clients and clean up on server shutdown
process.on('SIGTERM', () => {
for (const client of clients.values()) {
client.res.end(); // closes connection, triggers browser reconnect
}
server.close();
});Real-World Use Cases
AI/LLM Streaming
ChatGPT, Claude, and most AI APIs stream responses via SSE — each token arrives as a separate event.
Live Dashboards
Metrics, stock prices, and monitoring data pushed in real time without polling.
Notification Feeds
GitHub, Slack, and similar services push notifications to the browser via SSE.
CI/CD Build Logs
Stream build output line-by-line as it happens — naturally unidirectional.
Live Sports Scores
Score updates pushed to thousands of concurrent viewers over standard HTTP.
IoT Telemetry
Sensor data streamed to a browser dashboard — one-way data flow fits SSE perfectly.
References
- MDN: Server-Sent Events — overview of the SSE API, concepts, and browser support
- MDN: Using Server-Sent Events — implementation guide with event stream format and code examples
- MDN: EventSource API — full API reference for the browser-side EventSource interface
- WHATWG HTML Spec: Server-Sent Events — the canonical specification defining wire format, parsing rules, and reconnection behavior
- MDN: WebSockets API — WebSocket reference for comparison with SSE
- Can I Use: EventSource — browser compatibility data for the EventSource API
Need bidirectional messaging instead? Read the WebSockets Guide for the RFC 6455 protocol, server implementations, and reconnection strategies. Debugging an SSE response? Inspect the headers (Content-Type, Cache-Control, X-Accel-Buffering) with the HTTP Header Analyzer.