How to use server-sent events in Express
An Express route becomes a server-sent events stream when it sets Content-Type: text/event-stream, flushes the response head, and then writes data: lines instead of ending the response. The browser reads those lines with its built-in EventSource object, which parses the format and reconnects on its own when the connection drops.
The minimal streaming route
const express = require("express");
const app = express();
app.get("/events", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
});
res.flushHeaders();
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
}, 1000);
req.on("close", () => {
clearInterval(timer);
res.end();
});
});
app.listen(3000);
Three details in there do the real work. res.flushHeaders() sends the response head immediately, which is what tells the browser the stream is open rather than pending. Every message ends with two newline characters, one closing the data line and one closing the event block, and a message that only ends with a single newline sits in the client's parser until the next one pushes it out. And the close handler is not optional: without it the interval keeps firing for a client that navigated away, and every abandoned tab leaks a timer.
On the client there is nothing to install:
const stream = new EventSource("/events");
stream.onmessage = (e) => console.log(JSON.parse(e.data));
The stream runs over an ordinary HTTP GET, so it inherits your cookies, authentication middleware, and request logging with no extra work. If you are wondering whether that holds on older infrastructure, SSE does work over plain HTTP/1.1.
Naming events and letting clients resume
Two more fields turn a raw feed into something a client can subscribe to selectively and recover from. event names the message type, and id labels it so the browser can tell you where it left off:
res.write(`id: ${event.id}\nevent: order.updated\ndata: ${JSON.stringify(event)}\n\n`);
A client listens for the named type with stream.addEventListener("order.updated", handler). Messages sent without an event field still arrive through onmessage, so you can mix both.
The id field matters more than it looks. When the connection drops, EventSource reconnects automatically and includes the last id it saw in a Last-Event-ID request header, which arrives at your route like any other header:
app.get("/events", (req, res) => {
const since = req.header("Last-Event-ID");
// send everything newer than `since` before streaming live updates
});
Read that header and replay the gap from a database query or a short in-memory ring buffer of recent events. Skip it and reconnection silently drops whatever happened while the client was away, which is the bug people usually discover in production on a train. You can also write a retry: 5000 line to tell the browser how many milliseconds to wait before reconnecting; the default is three seconds in most browsers.
Keeping the connection alive through proxies
An idle SSE connection looks dead to anything sitting between your app and the browser. Nginx closes a proxied connection after 60 seconds of silence by default (proxy_read_timeout), and an AWS Application Load Balancer does the same at 60 seconds. Send a comment line on a timer to keep it open, because a line starting with a colon is a valid message the client ignores:
const keepalive = setInterval(() => res.write(": ping\n\n"), 20000);
Stopping proxies from buffering the stream
Buffering breaks SSE more often than the streaming code does. Express's compression middleware collects the response body to gzip it, so your messages arrive in bursts rather than when you write them; exclude the stream route with its filter option, or call res.flush() after each write. Nginx buffers proxied responses by default, which holds writes until its buffer fills, and the fix that does not require a config change is setting X-Accel-Buffering: no on the response, which nginx honors per response.
Browsers are the last constraint. Under HTTP/1.1 they allow six connections per host, and one open stream holds one of the six for the life of the page, so a user with several tabs can starve their own regular requests. Serving over HTTP/2 raises that ceiling far enough that it stops mattering.
When to reach for something else
SSE only flows one way. Anything the client needs to send goes out as a separate request, which is fine for a feed and awkward for a chat or a collaborative editor; WebSocket vs SSE walks through that decision. SSE also assumes a client sitting there holding a connection open, which no other company's backend is going to do for you. Notifying another server is a webhook's job: a short POST sent when there is something to report, compared side by side in webhooks vs server-sent events.
If your Express app needs to push events to your customers' servers rather than to their browsers, that is a delivery problem instead of a streaming one, and Svix handles the retries, signatures, and per-message delivery logs it requires.
Frequently asked questions
Why do my Express SSE messages arrive in batches?
Something is buffering the response. The usual culprits are the compression middleware, which collects the body before gzipping it, and nginx, which buffers proxied responses by default. Exclude the route from compression and set X-Accel-Buffering: no on the response.
Do I need to handle reconnection in the browser?
EventSource reconnects on its own, so the client side is covered. The server side is not: read the Last-Event-ID header on the new request and replay the events the client missed, otherwise the gap is lost silently.
Can server-sent events send data from the client to the server?
No. SSE is server to client only. Send client data with a normal fetch call to another route, or use a WebSocket if the traffic is constant in both directions.
Ready to send webhooks?
Svix handles signing, retries, rate limiting, and delivery observability for the webhooks you send to your users, so your team can stay focused on your product.
Start sending webhooks with Svix or read the build vs. buy analysis