Server-Sent Events
An HTTP response you keep open and write into, so the server can push updates to a browser over one request.
res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write('data: ' + text + '\n\n');Server-Sent Events, or SSE, is an HTTP response that never finishes. The client makes one ordinary GET request. The server sends the headers, then holds the response open and writes a line into it whenever there is news. The connection can stay open for hours.
Three headers set it up: Content-Type: text/event-stream, Cache-Control: no-cache and Connection: keep-alive. After that you push data with res.write(), the same method you use for an ordinary response body. You do not call res.end() until the feed is over.
The wire format
A message is one or more data: lines followed by a BLANK line. The blank line is what sends it. Three optional fields go in front of the data: event: names the message type, id: tags it, and retry: sets how long the client waits before reconnecting, in milliseconds.
A message with no event: field arrives as type message, so these two lines send the same thing:
res.write('data: hello\n\n'); // shorthand
res.write('event: message\ndata: hello\n\n'); // the longhand it stands for
What trips people up
SSE only goes one way, server to client. The client answers with normal requests. Anything between Node and the browser can hold the body in a buffer, so nothing arrives until that buffer fills; nginx needs the header X-Accel-Buffering: no, and response compression has to be off for this route. Every open feed also keeps a socket, so ten thousand readers means ten thousand sockets on that process.
Example
When to use it
- Push job or upload progress to a page without the page polling for it.
- Stream build or deployment logs into a browser as they are written.
- Keep a dashboard's counters current without a refresh button.
- Deliver notification badges to a signed-in user.
More examples
One endpoint to subscribe, one function to push
The shape every SSE feature ends up with: a registry of open responses and one broadcast function the rest of the app calls. Reach for it as soon as more than one reader needs the same updates.
const http = require('node:http');
const feeds = new Set(); // every response that is currently open
function broadcast(event, payload) {
const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const res of feeds) res.write(frame);
}
const server = http.createServer((req, res) => {
if (req.url !== '/events') { res.writeHead(404).end(); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // stops nginx holding the body in a buffer
});
feeds.add(res);
res.on('close', () => feeds.delete(res)); // the client left; stop tracking it
});
server.listen(3000);
// Anywhere else in the app, when a job moves on:
broadcast('job', { id: 7, state: 'running' });Event ids, so a reconnect does not lose messages
Use this when a missed message matters: deploy logs, order status, anything the reader has to see in full. Without ids a reconnect silently skips whatever happened while the line was down.
// Messages carry an id. When the connection drops, the client reconnects on
// its own and sends the last id it saw in the Last-Event-ID request header.
const log = [{ id: 1, text: 'queued' }, { id: 2, text: 'running' }];
function handler(req, res) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
});
const since = Number(req.headers['last-event-id'] || 0);
for (const entry of log.filter((e) => e.id > since)) {
res.write(`id: ${entry.id}\ndata: ${entry.text}\n\n`);
}
// A retry field on its own sets the client's reconnect delay to 5 seconds.
res.write('retry: 5000\n\n');
}The client side
Shows what the other end of your stream looks like, and why the format matters: the field names you write turn into the listener names the client uses. The automatic retry is the main reason to pick SSE over a WebSocket.
// In a browser, EventSource is always there. In Node.js 24 the global
// EventSource is still experimental (added in v22.3.0) and needs a flag:
// node --experimental-eventsource client.js
const source = new EventSource('http://127.0.0.1:3000/events');
// A named event: the "event:" field decides which listener fires.
source.addEventListener('job', (event) => {
console.log(JSON.parse(event.data)); // event.data is the text after "data: "
});
// Messages sent with no "event:" field arrive as type "message".
source.addEventListener('message', (event) => console.log('plain:', event.data));
// No reconnect code needed: EventSource retries by itself after a drop.
source.addEventListener('error', () => console.log('connection dropped'));
Discussion