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:
| 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.
Frequently Asked Questions
Can SSE send data from the client to the server?
No. SSE is unidirectional — server to client only. If you need to send data upstream, use a regular HTTP request (POST/PUT) alongside the SSE connection, or switch to WebSockets for full bidirectional communication.
Does SSE work with HTTP/2?
Yes, and it works better. HTTP/2 multiplexes SSE streams with other requests over a single TCP connection, eliminating the 6-connection-per-domain limit that exists on HTTP/1.1. This makes SSE practical for multi-tab applications.
How does SSE handle reconnection?
The browser automatically reconnects after a connection drop, waiting 3 seconds by default (configurable via the retry: field). On reconnect, it sends a Last-Event-ID header with the last received event ID, allowing the server to replay missed events.
Why would I choose SSE over WebSockets?
Choose SSE when the data flows one way (server to client). SSE gives you automatic reconnection, event resumption, HTTP/2 multiplexing, and works through all proxies and CDNs without configuration. WebSockets require you to implement all of this manually.
Can SSE send binary data?
No. SSE only supports UTF-8 text. For binary data, use WebSockets (which support ArrayBuffer and Blob) or encode the binary as Base64 — though this adds ~33% overhead.
What is the maximum number of SSE connections?
On HTTP/1.1, browsers limit you to 6 connections per domain across all tabs. On HTTP/2, the default is ~100 multiplexed streams per connection. Server-side, the limit depends on your infrastructure — each SSE client holds an open connection, so plan for concurrent connection capacity.
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