Server-Sent Events (SSE)
Server-Sent Events (SSE) let a server push a continuous stream of updates to a browser over a single, long-lived HTTP response. Unlike WebSockets, there is no separate protocol and no Upgrade handshake — SSE is plain HTTP with a text/event-stream body, which means it works through ordinary proxies, plays nicely with Express middleware, and reconnects automatically thanks to the browser’s built-in EventSource. It is the simplest, most robust way to deliver one-way realtime data such as notifications, live logs, progress bars, and dashboards.
How SSE works
An SSE endpoint is just an Express route that never calls res.end(). Instead of sending one response and closing, the server holds the connection open and writes small text frames as events occur. Each frame is one or more field: value lines terminated by a blank line. The browser parses these frames and fires events on an EventSource object.
The wire format has only a handful of fields:
| Field | Purpose |
|---|---|
data: | The payload. Multiple data: lines are concatenated with newlines. |
event: | A named event type; the client listens with addEventListener(name, ...). |
id: | An event ID the browser remembers and replays on reconnect. |
retry: | Reconnection delay in milliseconds the browser should use. |
| (comment) | A line starting with : is ignored — useful as a keep-alive ping. |
Every event must end with a blank line (\n\n). Forgetting that trailing newline is the single most common SSE bug — the browser buffers the frame forever and nothing fires.
A minimal SSE endpoint
Set the three response headers that mark the stream, flush them so the client connects immediately, then write events on a timer. Always clean up when the request closes.
const express = require('express');
const app = express();
app.get('/events', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders(); // send headers before the first event
let id = 0;
const timer = setInterval(() => {
const payload = JSON.stringify({ time: Date.now() });
res.write(`id: ${++id}\n`);
res.write(`event: tick\n`);
res.write(`data: ${payload}\n\n`);
}, 1000);
req.on('close', () => clearInterval(timer)); // client disconnected
});
app.listen(3000, () => console.log('SSE on http://localhost:3000/events'));
The raw bytes a client receives look like this:
Output:
id: 1
event: tick
data: {"time":1749868800000}
id: 2
event: tick
data: {"time":1749868801000}
Tip: Disable response buffering at every layer. Behind nginx, set
proxy_buffering off;(or send theX-Accel-Buffering: noheader), and if you use compression middleware, skip it for the stream — gzip buffers output and delays delivery.
Consuming events in the browser
The EventSource API does all the hard work: it opens the connection, parses frames, dispatches events, and reconnects on its own if the connection drops.
const source = new EventSource('/events');
// Default unnamed events arrive via onmessage
source.onmessage = (e) => console.log('message:', e.data);
// Named events use addEventListener
source.addEventListener('tick', (e) => {
const data = JSON.parse(e.data);
console.log('tick at', new Date(data.time).toISOString());
});
source.onerror = () => console.log('disconnected — browser will retry');
Reconnection and Last-Event-ID
SSE’s reconnection story is what sets it apart. When the connection drops, the browser waits (by default ~3 seconds, or whatever retry: you sent) and reconnects automatically. If you sent id: fields, the browser includes the most recent one in a Last-Event-ID request header on the new connection — letting the server resume exactly where it left off.
app.get('/events', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders();
// Tell the client to wait 5s before retrying
res.write('retry: 5000\n\n');
// Resume from where the client left off, if it reconnected
let cursor = Number(req.headers['last-event-id']) || 0;
const timer = setInterval(() => {
const event = getNextEvent(cursor); // your backlog/source
if (!event) return;
cursor = event.id;
res.write(`id: ${event.id}\n`);
res.write(`data: ${JSON.stringify(event.payload)}\n\n`);
}, 1000);
// Comment lines keep idle connections and proxies alive
const ping = setInterval(() => res.write(': keep-alive\n\n'), 15000);
req.on('close', () => {
clearInterval(timer);
clearInterval(ping);
});
});
function getNextEvent(afterId) {
// Replace with a real queue/DB lookup
return { id: afterId + 1, payload: { value: Math.random() } };
}
SSE versus WebSockets
Both keep a connection open, but they solve different problems. Choose SSE for server-to-client streams and WebSockets when the client must also push frequently.
| Aspect | Server-Sent Events | WebSockets |
|---|---|---|
| Direction | One-way (server → client) | Full-duplex |
| Protocol | Plain HTTP / text/event-stream | ws:// after Upgrade handshake |
| Reconnection | Automatic, with Last-Event-ID | Manual (you write the logic) |
| Data format | UTF-8 text only | Text and binary |
| Proxy/CDN friendliness | High — it’s just HTTP | Needs Upgrade passthrough |
| Express middleware | Runs normally | Bypassed on the upgrade request |
| Browser API | EventSource (built-in) | WebSocket (built-in) |
Warning: Over HTTP/1.1 a browser allows only ~6 connections per origin, and each
EventSourceconsumes one. Many open tabs can exhaust the pool. HTTP/2 multiplexes streams over one connection and removes this limit — serve SSE over HTTP/2 in production.
Best Practices
- Always write a trailing blank line (
\n\n) after every event, or the client will never see it. - Send periodic comment pings (
: keep-alive\n\n) so idle connections survive proxy and load-balancer timeouts. - Attach
id:fields and honor theLast-Event-IDheader so reconnecting clients resume without gaps. - Clean up timers and listeners in
req.on('close')to avoid leaking resources from abandoned streams. - Disable buffering and compression for the stream (
proxy_buffering off, skip gzip) so events flush instantly. - Prefer HTTP/2 in production to escape the ~6-connection-per-origin limit of HTTP/1.1.
- Keep payloads as compact JSON and reach for WebSockets only when you genuinely need client-to-server traffic.