Server-Sent Events (SSE) are the simplest way to push updates from a server to a browser. The client makes an ordinary HTTP GET, and the server never quite finishes the response: it keeps writing small text events whenever something happens. The browser’s EventSource API parses them and, if the connection drops, reconnects on its own and tells the server where it left off.
Interviewers bring SSE up when comparing real-time options (“polling, SSE or WebSockets?”) and then probe the parts that matter in production: how resuming works, what happens behind proxies, and how many connections a browser will hold open. This note builds a job-progress stream in Node, reads it raw with curl, and watches a real browser recover from a dropped connection.
The wire format
An SSE response has Content-Type: text/event-stream and a body made of lines. Each event is a few field: value lines followed by a blank line:
data:is the payload. Severaldata:lines in one event are joined with newlines.id:sets the event’s ID, which the browser remembers.event:names the event type, so clients can listen forprogressseparately frommessage.retry:tells the client how many milliseconds to wait before reconnecting.- A line starting with
:is a comment. Clients ignore it, which makes it a handy heartbeat.
Here’s the raw stream from the test server, read with curl (-N turns off curl’s output buffering):
curl -sN --max-time 3 http://127.0.0.1:8099/jobs/42/eventsretry: 1000
: connected
id: 1
event: progress
data: {"job":42,"progress":20,"done":false}
id: 2
event: progress
data: {"job":42,"progress":40,"done":false}
[curl exit 18]The stream stopped after event 2 because the server deliberately dropped its first connection after writing event 3. curl’s exit code 18 means the transfer ended early. Notice that event 3 never arrived, even though the server wrote it. That detail matters in a moment.
A job progress stream in Node
The server keeps the job’s events in an array so it can replay them. It reads Last-Event-ID from the request, skips anything the client already has, and drops the first connection after event 3 to simulate a network failure:
http.createServer((req, res) => {
const n = ++connection;
const lastId = Number(req.headers['last-event-id'] ?? 0);
console.log(`server: connection ${n}, Last-Event-ID: ${req.headers['last-event-id'] ?? '(none)'}`);
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'x-accel-buffering': 'no' });
res.write('retry: 1000\n\n'); // reconnect after 1 s if the stream drops
res.write(': connected\n\n'); // a comment line, ignored by clients
const pending = events.filter((e) => e.id > lastId);
let sent = 0;
const timer = setInterval(() => {
const e = pending[sent++];
if (!e) return clearInterval(timer);
res.write(`id: ${e.id}\nevent: progress\ndata: ${JSON.stringify(e.data)}\n\n`);
if (n === 1 && e.id === 3) { clearInterval(timer); res.destroy(); } // simulate a dropped connection
}, 300);
req.on('close', () => clearInterval(timer));
}).listen(8099, '127.0.0.1');The browser side is a few lines:
const source = new EventSource('/jobs/42/events');
source.addEventListener('progress', (e) => {
log('progress id=' + e.lastEventId + ' ' + e.data);
if (JSON.parse(e.data).done) { source.close(); log('closed by client'); }
});
source.onerror = () => log('error event, readyState=' + source.readyState);Reconnecting with Last-Event-ID
Opened in Chromium, the page logged:
progress id=1 {"job":42,"progress":20,"done":false}
progress id=2 {"job":42,"progress":40,"done":false}
error event, readyState=0
progress id=3 {"job":42,"progress":60,"done":false}
progress id=4 {"job":42,"progress":80,"done":false}
progress id=5 {"job":42,"progress":100,"done":true}
closed by clientAnd the server:
server: connection 1, Last-Event-ID: (none)
server: connection 2, Last-Event-ID: 2When the connection dropped, EventSource fired an error event with readyState 0 (CONNECTING), waited the 1,000 ms the server asked for with retry:, and reconnected. It sent Last-Event-ID: 2, the last ID it actually received. The server had written event 3, but those bytes died with the connection, and the server resent it. No application code handled any of this.
The lessons for the server side:
- Keep a replay window. Resuming only works if the server can produce every event after any recent ID, from memory, Redis or the database. If an ID is too old to replay, send the current full state instead.
- Never assume a write was delivered. As event 3 showed, writing to the socket isn’t delivery. The client’s
Last-Event-IDis the only reliable record. - Make handlers idempotent. With different timing a client can receive an event, lose the connection before the next ID is processed, and see it again. Applying “progress is 60%” twice is harmless; “add 20%” isn’t.
Two controls end the stream for good. The client can call close(). The server can answer a reconnect with 204 No Content, which tells EventSource to stop. A response with an error status or the wrong content type also makes it give up, moving to readyState 2 (CLOSED), so check for that state if the stream must stay alive.
What breaks SSE in production
The protocol is plain HTTP, which is its strength, but the infrastructure between client and server was built for responses that finish.
- Buffering proxies. nginx buffers upstream responses by default, so events arrive in bursts or only when the stream ends. Disable buffering for the route, or send
X-Accel-Buffering: noas the server above does. - Compression. Gzip middleware may wait to fill a block before sending anything. Exclude
text/event-streamor flush after each event. - Idle timeouts. Load balancers close connections that are quiet for too long (AWS’s application load balancer defaults to 60 seconds). Send a comment line every 15 to 30 seconds so a quiet job doesn’t look like a dead connection.
- The six-connection limit. Over HTTP/1.1, browsers allow about six connections per origin, shared across tabs. Each open
EventSourceholds one, so a user with several tabs open can find the rest of the site hanging. Over HTTP/2, streams share one connection and the limit is negotiated (commonly 100). - No custom headers.
EventSourcecan’t set anAuthorizationheader. Use cookies, or a short-lived token in the URL (knowing URLs end up in logs), or read the stream withfetchand parse it yourself.
SSE, WebSockets or polling
Polling is the simplest: ask every N seconds. Latency is up to N seconds and most requests return nothing. SSE pushes immediately, reconnects for you, works over HTTP/2 and through ordinary HTTP infrastructure, and carries cookies like any request. It’s one-way and text-only; the client sends anything else with normal requests. LLM APIs commonly stream generated tokens as SSE for exactly these reasons. WebSockets give a full two-way channel that carries binary data, at the cost of a separate protocol after the upgrade and reconnection logic you write yourself. They fit chat, collaborative editing and games, where the client sends constantly too.
A common misconception is that SSE is outdated next to WebSockets. For dashboards, notifications and progress bars, it’s often the better fit because it’s less to build and operate.
Interview practice
Design live progress updates for a page that shows a long-running data export. Users sometimes lose connectivity for a few seconds, traffic passes through nginx and a cloud load balancer with a 60-second idle timeout, and up to 50,000 people may be watching exports at once.
Answer
I’d use SSE: the updates only flow from server to client, and EventSource gives automatic reconnection. Each export job writes progress events with increasing IDs to a store the API servers share, such as a Redis stream with a few minutes of retention. The SSE endpoint reads Last-Event-ID, replays everything after it from the store, then subscribes for new events. If the ID has fallen out of retention, it sends a single event with the job’s full current state. Events carry absolute values (“60%, 3 of 5 files”), so a duplicate after a reconnect is harmless.
For the infrastructure, I’d disable proxy buffering on that route (X-Accel-Buffering: no or the nginx setting), exclude the stream from compression, set retry: to a few seconds, and send a comment heartbeat every 20 seconds so neither nginx nor the load balancer drops quiet connections. I’d serve it over HTTP/2 so tabs don’t run into the six-connection limit. When the job finishes, the server sends a final done event and returns 204 to any later reconnect.
Fifty thousand mostly idle connections are fine for an event-driven server, but I’d check file descriptor limits and the load balancer’s connection quotas, and I’d put the SSE endpoint on its own instances so streams can’t use up capacity meant for regular requests.