Server-Sent Events in Node.js: live updates with EventSource, reconnects and Last-Event-ID
Server-Sent Events (SSE) let a server push messages to the browser over an ordinary HTTP response that simply never ends. On the client, the built-in EventSource API parses the stream, dispatches events and reconnects automatically. Data flows one way, server to browser. For notifications, progress bars, live dashboards or streaming tokens from an LLM, that's usually all you need, and you don't need a WebSocket server or any dependencies.
The demo below was run with Node.js 24 and 26, and the client behaviour was tested in headless Chromium 153, Firefox 155 and WebKit 26.6 using the official Playwright Docker image. All three behaved identically.
The wire format in 30 seconds
An SSE response has content-type: text/event-stream and consists of plain-text messages separated by a blank line. This is the raw stream from the demo server below:
$ curl -N localhost:3000/events
retry: 2000
id: 3
event: tick
data: {"time":"2026-10-01T07:35:13.179Z"}
id: 4
event: tick
data: {"time":"2026-10-01T07:35:14.186Z"}data:is the payload. It's always text, so send JSON andJSON.parseit on the client.event:names the event, so the client canaddEventListener('tick', …). Without it you get a genericmessageevent.id:is remembered by the browser and sent back as aLast-Event-IDheader when it reconnects.retry:sets the reconnect delay in milliseconds. (The first version of this post claimed the delay couldn't be configured. It can, and this is how.)- A line starting with
:, such as: keep-alive, is a comment that the client ignores. It's useful as a keep-alive.
The server: node:http, no framework
server.mjs. It broadcasts a tick event every second to every connected browser:
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
const clients = new Set();
const recent = []; // the last 100 events, so reconnecting clients can catch up
let lastId = 0;
function send(res, { id, event, data }) {
res.write(`id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
function broadcast(event, data) {
const message = { id: ++lastId, event, data };
recent.push(message);
if (recent.length > 100) recent.shift();
for (const res of clients) send(res, message);
}
const server = createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
if (pathname === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
return res.end(readFileSync('index.html'));
}
if (pathname === '/events') {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
'x-accel-buffering': 'no', // ask nginx not to buffer the stream
});
res.write('retry: 2000\n\n'); // reconnect 2s after the connection drops
// A reconnecting browser sends the id of the last event it saw:
// replay whatever it missed. A brand-new client only gets new events.
const lastSeen = req.headers['last-event-id'];
console.log(`client connected, Last-Event-ID: ${lastSeen ?? '(none)'}`);
if (lastSeen) {
for (const message of recent) if (message.id > Number(lastSeen)) send(res, message);
}
clients.add(res);
req.on('close', () => clients.delete(res));
return;
}
if (pathname === '/events-done') {
// 204 No Content tells EventSource to stop reconnecting for good.
console.log('client asked /events-done → 204');
return res.writeHead(204).end();
}
res.writeHead(404).end();
});
setInterval(() => broadcast('tick', { time: new Date().toISOString() }), 1000);
// A comment line every 15s keeps proxies from closing an idle connection.
setInterval(() => { for (const res of clients) res.write(': keep-alive\n\n'); }, 15_000);
server.listen(3000, () => console.log('SSE demo on http://localhost:3000'));The important details:
- Keep a set of open responses and remove each one on
req.on('close'). Otherwise you'll keep writing to clients that left, and leak memory. - Keep a short history (here, the last 100 events) and replay everything newer than
Last-Event-IDwhen a client reconnects, so a dropped connection doesn't lose messages. Only replay when the header is present. If you replay to every new client too, anyone who opens the page gets a burst of old events. cache-control: no-cacheandx-accel-buffering: nostop caches and nginx from holding the stream back. Compression middleware can buffer it too, so exclude this route from compression.- A comment every 15 seconds keeps load balancers and proxies from closing what looks like an idle connection.
With Express the route body is the same, because res is the Node response. In Fastify, call reply.hijack() first to tell Fastify you're sending the response yourself, then write to reply.raw.
The client
index.html:
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>SSE demo</title>
<ul id="log"></ul>
<script>
const log = (text) => {
const li = document.createElement('li');
li.textContent = text; // textContent, not innerHTML: never inject server data as HTML
document.getElementById('log').append(li);
};
const events = new EventSource('/events');
events.addEventListener('open', () => log('connected'));
events.addEventListener('tick', (e) => {
const { time } = JSON.parse(e.data);
log(`#${e.lastEventId} ${time}`);
});
events.addEventListener('error', () => {
// CONNECTING means the browser is about to retry; CLOSED means it gave up.
log(events.readyState === EventSource.CONNECTING ? 'lost connection, retrying…' : 'closed');
});
</script>Use textContent to show server data. The original version of this post used innerHTML, which turns any message containing HTML into a script injection.
What happens when the connection drops
To test reconnection, the server dropped every open stream 3.5 seconds in, the way a deploy or a crash would. This is what the page logged (the timestamps are omitted):
connected, #1, #2, #3, lost connection, retrying…, connected, #4, #5, #6, #7And the server's side of the same run in Chromium:
0.7s client connected, Last-Event-ID: (none)
3.5s dropping 1 connection(s) after event 3
5.5s client connected, Last-Event-ID: 3- The browser reconnected 2.0 seconds after the
errorevent, exactly theretry: 2000the server asked for (2.87 s → 4.87 s in Chromium, 2.36 s → 4.37 s in Firefox, 2.65 s → 4.66 s in WebKit). - It sent
Last-Event-ID: 3, and the server replayed events #4 and #5, which were broadcast while nobody was connected. No ids were missing in any of the three browsers. - During the outage,
readyStatewas0(CONNECTING), which is how the client knows to show "retrying…" rather than "closed".
Stopping for good
Because EventSource reconnects on its own, ending the response isn't enough to stop it: the browser just comes back after the retry delay. There are two clean ways to stop:
- From the client: call
events.close(). - From the server: answer the (re)connection with
204 No Content. Per the spec, any other non-200 status or a wrong content type also makes the browser give up. In the test, the/events-doneendpoint got exactly one request in each browser. After 5 secondsreadyStatewas2(CLOSED), with oneerrorevent and no retries. Use this when a job is finished or a user's session has ended.
The six-connection limit
Over HTTP/1.1, browsers allow only six open connections per origin, and each EventSource holds one for as long as it's open. The test opened streams in one page and then made an ordinary request:
page with 5 open EventSources, then fetch('/'): fetch ok in 6ms
page with 6 open EventSources, then fetch('/'): still pending after 3sThat happened in all three browsers. With six streams open, even loading an image or calling your API from that origin just waits. MDN notes that the limit is per browser and domain, so it spans tabs, and that Chrome and Firefox have marked it "won't fix". The ways around it:
- Serve over HTTP/2. Streams are multiplexed over one connection, and the number of simultaneous streams is negotiated (MDN: it defaults to 100). If Node terminates TLS itself, use
http2.createSecureServer(). Usually your proxy or CDN already speaks HTTP/2 to browsers. - Use one stream per page, with several
event:types on it, rather than oneEventSourceper widget. - For many tabs, share one connection through a
SharedWorkerorBroadcastChannel.
Reading SSE from Node.js
Node.js ships EventSource too, but in Node 24 and 26 it's still behind a flag (added in 22.3 and 20.18):
// Run with: node --experimental-eventsource node-client.mjs
const events = new EventSource('http://localhost:3000/events');
let count = 0;
events.addEventListener('tick', (e) => {
console.log(`#${e.lastEventId}`, JSON.parse(e.data));
if (++count === 3) events.close();
});$ node --experimental-eventsource node-client.mjs
(node:17) [UNDICI-ES] Warning: EventSource is experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)
#1 { time: '2026-10-01T07:33:11.119Z' }
#2 { time: '2026-10-01T07:33:12.124Z' }
#3 { time: '2026-10-01T07:33:13.126Z' }Without the flag, EventSource is undefined. For production use, wait for it to become stable, use the eventsource package from npm, or read fetch()'s response body yourself. Note that browser EventSource can only make GET requests and can't set custom headers like Authorization, so it relies on cookies. If you need a POST body or a bearer token, as with most LLM streaming APIs, use fetch() and parse the stream yourself.
Sources & further reading
- HTML Standard: Server-sent events (format,
retry,Last-Event-ID, 204 handling) - MDN: EventSource (including the connection-limit warning)
- MDN: Using server-sent events
- Node.js docs: --experimental-eventsource
- nginx: proxy_buffering and X-Accel-Buffering
- Fastify: reply.hijack()