How to build a production server-sent events endpoint in Express
To serve server-sent events from Express, open a GET route, set Content-Type: text/event-stream, and write id:, event:, and data: lines without ever calling res.end(). Surviving production takes five more pieces: a client registry for broadcasting, replay from Last-Event-ID, a heartbeat, a policy for slow clients, and proxy settings that stop buffering.
The file below does all of it. If you need the wire format and the browser’s EventSource API explained first, start with the definition linked above; this page is about the server.
The endpoint
const express = require("express");
const app = express();
const clients = new Set();
const history = [];
const HISTORY_SIZE = 500;
let lastId = 0;
const frame = (e) => `id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`;
function send(res, chunk) {
if (res.writableEnded) return;
if (!res.write(chunk)) res.end();
}
function publish(type, data) {
const event = { id: ++lastId, type, data };
history.push(event);
if (history.length > HISTORY_SIZE) history.shift();
for (const res of clients) send(res, frame(event));
}
function replay(res, since) {
const oldest = history.length ? history[0].id : lastId + 1;
if (since > lastId || since < oldest - 1) {
res.write("event: reset\ndata: {}\n\n");
return;
}
for (const e of history) if (e.id > since) res.write(frame(e));
}
app.get("/events", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
});
res.write("retry: 5000\n\n");
const since = req.get("Last-Event-ID") ?? req.query.lastEventId;
if (since) replay(res, Number(since));
clients.add(res);
const heartbeat = setInterval(() => send(res, ": ping\n\n"), 15000);
res.on("close", () => {
clearInterval(heartbeat);
clients.delete(res);
});
});
const server = app.listen(3000);
Call publish("order.updated", order) from wherever the change happens, such as the route that saves the order. The browser side tracks the last id it saw so it can resume even after it has to start a fresh connection:
let lastSeen = "";
function connect() {
const stream = new EventSource(`/events?lastEventId=${lastSeen}`);
stream.addEventListener("order.updated", (e) => {
lastSeen = e.lastEventId;
render(JSON.parse(e.data));
});
stream.addEventListener("reset", () => reloadState());
stream.onerror = () => {
if (stream.readyState === EventSource.CLOSED) setTimeout(connect, 10000);
};
}
connect();
The rest of this page explains why each line is there, because each one fixes a failure that only shows up once real users and real infrastructure are involved.
Why these headers and no others
Cache-Control: no-cache keeps intermediaries from caching the stream. The no-transform directive does a second job: the Express compression middleware never compresses a response carrying it, so an app.use(compression()) elsewhere in your app will not hold events back to fill a gzip window. If you would rather compress the stream, drop no-transform and call res.flush(), which compression adds, after every write. X-Accel-Buffering: no tells nginx to pass this one response through unbuffered without touching its config.
There is no Connection: keep-alive header. The response stays open because the route never ends it, not because of a header, and HTTP/2 forbids connection-specific headers anyway. The retry: 5000 line sets the reconnection delay in milliseconds; without it the browser picks its own, which the spec only describes as a few seconds.
Replaying what a client missed
When a connection drops, EventSource reconnects on its own and sends the last id it received in a Last-Event-ID request header. That header only exists on automatic reconnects. A page reload, or the manual reconnect in the client code above, starts a new EventSource that sends nothing, which is why the route also accepts a lastEventId query parameter and prefers the header when both are present.
The ring buffer keeps memory bounded, and the reset event covers the two cases it cannot. A client that has been away longer than the buffer holds would otherwise get a partial replay with a silent hole in it. A client that reconnects after the process restarted would send an id higher than anything the fresh counter has produced, and the e.id > since check would skip every new event. In both cases the server tells the client to refetch full state from your regular API instead of pretending the stream is intact.
The in-memory counter is the weak point. In production, use ids from your database or event store so they survive restarts and mean the same thing on every instance.
Payload encoding matters too. A newline inside a data: line ends that line, so multi-line text needs one data: prefix per line. JSON.stringify escapes newlines, which is the main reason to send JSON even for simple values.
Heartbeats and slow clients
Proxies close connections that go quiet. Nginx’s proxy_read_timeout defaults to 60 seconds between reads, and an AWS Application Load Balancer’s idle timeout also defaults to 60 seconds. A line starting with a colon is a comment the browser ignores, so writing : ping every 15 seconds keeps the connection alive at no cost to the client. The HTML spec suggests the same interval for exactly this reason.
Slow clients are the failure nobody tests for. res.write() returns false when Node had to queue the chunk in memory because the socket could not take it. If a phone on a bad connection stops reading while you keep broadcasting, that queue grows without limit. The send helper ends the response instead. The browser reconnects with Last-Event-ID, and the replay buffer catches it up. Disconnect-and-replay is simpler than per-client queues, and it reuses code you needed anyway.
Listen for close on res, not req. Since Node 16 the request’s close event fires when the request has been fully received, which is not the same as the client leaving. The response’s close fires when the connection actually ends, and it is where the interval and the registry entry get cleaned up.
Proxies and the browser connection limit
Behind nginx, the header above disables buffering per response. Setting it in the location block as well protects you when someone removes the header later:
location /events {
proxy_pass http://127.0.0.1:3000;
proxy_buffering off;
proxy_read_timeout 1h;
}
The browser has its own limit. Over HTTP/1.1, a browser allows six connections per domain, shared across every tab, and each open stream holds one for the life of the page. Chrome and Firefox have both marked raising it as “Won’t fix”. Over HTTP/2 the limit is negotiated and defaults to 100 streams. Express’s built-in server speaks HTTP/1.1, so terminate HTTP/2 at the proxy, load balancer, or CDN in front of it. Does SSE work over HTTP? covers how the stream behaves on each protocol version.
Authentication and stopping a stream on purpose
EventSource cannot set request headers, so an Authorization: Bearer header is out. On the same origin, the browser sends cookies and your normal session middleware runs before the route. Cross-origin, create the stream with new EventSource(url, { withCredentials: true }) and respond with Access-Control-Allow-Origin set to the exact origin, plus Access-Control-Allow-Credentials: true. This missing-header gap is the most common reason people assume the API is abandoned; it is not deprecated.
Status codes have a sharp edge. Any response other than a 200 with text/event-stream makes the browser give up for good rather than retry. That is useful when a user logs out: answer the next request with 204 No Content and the reconnect loop stops. It is also how a single 502 from a load balancer during a deploy, or a 401 from an expired session, silently ends a stream. That is why the client code checks for EventSource.CLOSED and reconnects itself.
Running more than one instance
clients and history live in one process. Run two instances behind a load balancer and an event published on the first never reaches clients connected to the second. Publish through Redis pub/sub or Postgres LISTEN/NOTIFY instead, and have every instance subscribe and write to its own clients. Keep the replay history in a shared store as well. A Redis stream works well, since its entry ids are already ordered and can double as event ids.
Deploys need one more step. server.close() stops accepting connections but waits for open responses to finish, and these never finish, so shutdown hangs until your orchestrator kills the process. End the streams first, so browsers reconnect to a healthy instance and replay from where they were:
process.on("SIGTERM", () => {
for (const res of clients) res.end();
server.close();
});
To check all of this locally, run curl -N localhost:3000/events, where -N turns off curl’s own buffering. Then add -H "Last-Event-ID: 3" to confirm the replay.
When SSE is the wrong tool
SSE only flows from server to browser. If the client sends as often as it receives, as in chat or collaborative editing, WebSocket vs SSE walks through when a socket earns its extra work. SSE also assumes a client sitting there with a connection open, which another company’s backend will not do for you. Notifying another server is a webhook’s job, compared side by side in webhooks vs server-sent events. If your Express app needs to push the same events to your customers’ servers, Svix handles the retries, signatures, and per-message delivery logs that delivery requires.
Frequently asked questions
Why do my Express SSE messages arrive in batches?
Something between your route and the browser is buffering. The usual causes are the compression middleware, which holds output to fill its gzip window, and nginx, which buffers proxied responses by default. Send Cache-Control: no-cache, no-transform so compression skips the stream, and X-Accel-Buffering: no so nginx passes it through.
Why did my EventSource stop reconnecting?
The browser only retries after network errors and normally ended streams. If a reconnect attempt gets any response other than 200 with Content-Type text/event-stream, such as a 401 or a 502 during a deploy, it closes the stream for good. Check for readyState EventSource.CLOSED in the error handler and create a new EventSource yourself.
How do I scale SSE across multiple Express servers?
Each process only knows its own connected clients, so publish events through a shared channel such as Redis pub/sub or Postgres LISTEN/NOTIFY and have every instance forward them to its own clients. Keep the replay history in a shared store too, so a client that reconnects to a different instance can still resume from its Last-Event-ID.