Ch. 20 · Networking

Server-Sent Events and One-Way Updates

How Server-Sent Events stream updates over plain HTTP, how EventSource resumes with Last-Event-ID after a drop, and what proxies do to streams.

~6 min readadvancedupdated Oct 11, 2026

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. Several data: 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 for progress separately from message.
  • 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/events
Terminal
retry: 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]
Text

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');
JavaScript

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);
JavaScript

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 client
Text

And the server:

server: connection 1, Last-Event-ID: (none)
server: connection 2, Last-Event-ID: 2
Text

When 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-ID is 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: no as the server above does.
  • Compression. Gzip middleware may wait to fill a block before sending anything. Exclude text/event-stream or 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 EventSource holds 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. EventSource can’t set an Authorization header. Use cookies, or a short-lived token in the URL (knowing URLs end up in logs), or read the stream with fetch and 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.

More in Networking

read ✓Networking · mid

Networking: HTTP Redirects

Test 301, 302, 303, 307 and 308 against a local Node server with curl and fetch, and see which codes turn a POST into a GET and why.

~6 min readread →
read ✓Networking · mid

HTTP Connection Reuse and Latency

Measure what keep-alive saves, size a client connection pool with Little's law, and retry safely when a pooled socket was closed under you.

~6 min readread →
read ✓Networking · mid

CORS and Browser Response Access

CORS decides whether a page may read a cross-origin response, not whether it is sent. A browser test shows a blocked POST still saved.

~6 min readread →
esc