See It Flow

Core Concepts

Server-Sent Events

Open a connection once. The server pushes updates whenever they happen. No repeated requests.

Mangesh MandlikLast updated:

What Are Server-Sent Events?

A long-lived HTTP response that carries text-framed events from server to browser

Server-Sent Events (SSE) let a server send a sequence of text-framed events over a long-lived HTTP response. The browser's EventSource API receives each message as it arrives and normally reconnects on its own after an interruption. SSE is one-way at the application stream level: the server pushes events to the client, while anything the client wants to say goes over an ordinary HTTP request or some other channel.

A build dashboard is the natural example. A user starts a build with POST /builds, then opens GET /builds/42/events and receives queued, running, test_passed and completed events without polling. The browser renders progress as the server writes each event to the response. Everything is plain HTTP: the same URL space, cookies, TLS, load balancers and access logs as the rest of the application.

The mental model is a response that never quite finishes. An ordinary JSON request returns a complete body and the connection goes back to being idle. An SSE response sends its headers, then keeps writing small framed messages onto the same body for minutes or hours. The client is reading a file that is still being written. That is why the protocol itself is trivial and almost every real problem lives in what sits between the two ends: proxies that buffer or time out, compression layers, connection limits, and the question of what the server remembers after the connection is gone.

One build, one request, many eventsThe browser starts a build with POST /builds and receives 201 with the build id. It then opens GET /builds/42/events and the server answers 200 with Content-Type text/event-stream. The response stays open. As the build runner reports queued, running, test progress and completion, the API server writes an event frame for each one onto the same response. When the completed event arrives the browser calls source.close(), because a normal end of stream would otherwise make EventSource reconnect.BrowserEventSourceAPI serverGET …/eventsBuild runnerproduces events1. POST /builds2. 201 {"id":"42"}3. GET /builds/42/events4. 200 text/event-stream (open)5. queued → running6. id: build-42:1 event: status7. tests 65 %8. id: build-42:2 event: progress9. build finished10. id: build-42:3 event: completed11. source.close()
One build, one request, many events. The POST and the GET are ordinary requests that finish normally. Only the second response is long-lived, and everything after message 4 travels on it, one frame per event. Dashed arrows are bytes on that single response. The client closes the stream itself when the final event arrives.

Compare this with the neighbouring techniques. Polling asks again on a timer. Long polling parks one request until something happens, answers, and the client must immediately ask again. SSE answers once and keeps answering. In the figure, messages 1 and 2 are an ordinary request and response; message 3 is the GET whose response, from message 4 onward, is a single response whose body keeps growing. Because a streaming response behaves differently from a short JSON request, intermediaries that treat responses as finite documents (buffering them, idling them out, compressing them) can delay or terminate the stream unless they are configured for it. The rest of the lesson follows that chain: frames on the wire, the client's reconnect rules, replay, proxies, limits, slow clients and operations.

Visual Story

One connection → events pushed as they happen → drop → reconnect with Last-Event-ID

Server-Sent Events

Server-Sent Events
Connection Opened
0 / 0
📡Up nextEvents Pushed

⤢ Expand for synchronized narration timeline

Understanding the Wire Format

Field lines, blank-line dispatch, and why raw JSON is not SSE

The response uses Content-Type: text/event-stream and UTF-8 text. Each event is made of field lines and is terminated by a blank line:

A response carrying two events
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

id: build-42:17
event: progress
data: {"build_id":"42","step":"tests","percent":65}

id: build-42:18
event: completed
data: {"build_id":"42","result":"success"}

The first lines are the ordinary HTTP response head. The body is the event stream; nothing in it says how long the stream will last.

The grammar is small. A line is field: value, and one space after the colon is dropped. Lines end with LF, CRLF or a lone CR. The recognised fields are:

  • data: carries the payload. It may appear several times; the parser joins consecutive data lines with newline separators and strips the final newline.
  • event: names the event type. Without it the event is of type message, which is what source.onmessage receives; named events need addEventListener.
  • id: sets the last event ID that the browser sends back in the Last-Event-ID request header when it reconnects. An ID containing a null character is ignored.
  • retry: with an integer suggests the reconnect delay in milliseconds. Anything that is not all digits is ignored.
  • A line starting with : is a comment. The parser ignores it, which makes it the standard heartbeat. Unknown field names are ignored too.

A blank line dispatches the event. Until it arrives, the fields are only buffered. This is the most common source of "my events never show up" reports: SSE is not raw JSON streamed without framing, and a missing \n\n means the browser is waiting politely for the end of an event that has already been sent. Two further details matter in practice. A blank line with no data: buffered dispatches nothing, and if the connection ends mid-event the buffered fields are discarded rather than delivered half-finished.

Parsing an event-stream frame: multi-line dataLines on the wire, with what the parser does with each: "id: build-42:19" buffer the last event ID; "event: log" event type = "log"; "data: step 1/3" append "step 1/3" + newline; "data: step 2/3" append "step 2/3" + newline; "⏎ (blank line)" dispatch the event. Result: log event, data = "step 1/3\nstep 2/3", lastEventId = "build-42:19". Two data lines. The parser appends each value followed by a newline and removes the final newline at dispatch, so the listener sees the two lines joined by a single newline. The blank line is what triggers dispatch. The ID is committed on dispatch.id: build-42:19→ buffer the last event IDevent: log→ event type = "log"data: step 1/3→ append "step 1/3" + newlinedata: step 2/3→ append "step 2/3" + newline⏎ (blank line)→ dispatch the eventlog event, data = "step 1/3\nstep 2/3", lastEventId = "build-42:19"
Parsing an event-stream frame: multi-line data. Lines on the wire, with what the parser does with each: "id: build-42:19" buffer the last event ID; "event: log" event type = "log"; "data: step 1/3" append "step 1/3" + newline; "data: step 2/3" append "step 2/3" + newline; "⏎ (blank line)" dispatch the event. Result: log event, data = "step 1/3\nstep 2/3", lastEventId = "build-42:19". Two data lines. The parser appends each value followed by a newline and removes the final newline at dispatch, so the listener sees the two lines joined by a single newline. The blank line is what triggers dispatch. The ID is committed on dispatch.

Two data lines. The parser appends each value followed by a newline and removes the final newline at dispatch, so the listener sees the two lines joined by a single newline. The blank line is what triggers dispatch. The ID is committed on dispatch.

The third example is the one to internalise. Event 20 was fully written by the server but never terminated. The client does not see it, its last committed ID stays at 19, and after reconnecting it receives 20 again. So the delivery guarantee you can build on SSE is at least once, and handlers have to tolerate the same event twice. The Idempotency lesson covers the general technique; here the cheap version is to remember the highest sequence number you applied and ignore anything at or below it.

A server you can run

The following is a complete, dependency-free Node server for the build example. It writes the three events one second apart, includes id: fields, and, because it reads the Last-Event-ID header, picks up where a reconnecting client left off. Three response details do real work. no-cache stops intermediaries from storing the stream, no-transform asks them not to re-encode it, and X-Accel-Buffering: no is honoured by nginx to disable buffering for this response. Writing the first bytes immediately sends the response head, so the browser fires open without waiting for the first real event.

Minimal SSE server (Node 18+)
// server.mjs: node server.mjs   (Node 18+, no dependencies)
import http from 'node:http';

function frame({ id, event, data, retry }) {
  let out = '';
  if (retry !== undefined) out += `retry: ${retry}\n`;
  if (id !== undefined) out += `id: ${id}\n`;
  if (event) out += `event: ${event}\n`;
  const text = typeof data === 'string' ? data : JSON.stringify(data);
  for (const line of text.split(/\r\n|\r|\n/)) out += `data: ${line}\n`;   // one data: line per line of text
  return out + '\n';                                                          // the blank line dispatches the event
}

const steps = [
  ['status',    { state: 'running' }],
  ['progress',  { step: 'tests', percent: 65 }],
  ['completed', { result: 'success' }],
];

http.createServer((req, res) => {
  if (req.url !== '/builds/42/events') return res.writeHead(404).end();

  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    'X-Accel-Buffering': 'no', // nginx: do not buffer this response
  });
  res.write('retry: 5000\n\n');   // headers + first bytes leave immediately; no data, so nothing dispatches

  // Resume after the last event the client reported. Event ids are "build-42:<n>".
  let sent = Number(/^build-42:(\d+)$/.exec(req.headers['last-event-id'] ?? '')?.[1] ?? 0);

  const timer = setInterval(() => {
    if (sent >= steps.length) { clearInterval(timer); return res.end(); }
    const [event, data] = steps[sent++];
    res.write(frame({ id: `build-42:${sent}`, event, data }));
  }, 1000);
  req.on('close', () => clearInterval(timer));          // client went away: stop producing
}).listen(3000);

Two framing hazards deserve a line of defence in any real implementation. A payload containing newlines must be split into one data: line per line of text (the helper does this), otherwise the second line is parsed as a field name and silently dropped. And never put user-controlled text into the event: or id: value without removing newlines, because a newline there lets the sender forge extra fields. JSON from JSON.stringify contains no raw newlines, which is one reason JSON payloads are the usual choice.

How EventSource Connects, Fails and Retries

Three states, one retry rule, and the cases where the browser gives up

The browser side is a few lines of JavaScript:

Browser client
const source = new EventSource('/builds/42/events', { withCredentials: true });

source.addEventListener('progress', (event) => {
  const update = JSON.parse(event.data);
  renderBuildProgress(update);
});

source.addEventListener('completed', (event) => {
  const result = JSON.parse(event.data);
  renderFinalResult(result);
  source.close();               // otherwise the server ending the response triggers a reconnect
});

source.onerror = () => {
  // The browser may reconnect automatically; show degraded connectivity.
  if (source.readyState === EventSource.CLOSED) {
    // It will NOT reconnect (non-200, wrong Content-Type, 204). Create a new EventSource yourself.
  }
};

The calls hide a state machine that decides which failures heal themselves. A new EventSource starts in CONNECTING. A 200 response with text/event-stream moves it to OPEN and fires open. If an open connection drops, or the network fails, it fires error, returns to CONNECTING, waits the retry delay, and requests again. That is the automatic reconnection people mean.

EventSource readyState: when the browser retries and when it gives upThree states. CONNECTING (readyState 0) becomes OPEN (1) when the server answers 200 with Content-Type text/event-stream. A dropped connection or network error moves OPEN back to CONNECTING after firing an error event; the browser waits the retry delay and requests again, sending Last-Event-ID if it has one. From CONNECTING, a response with a status other than 200, a wrong Content-Type, or HTTP 204 moves to CLOSED and the browser does not retry. Calling close() from your code, drawn here from OPEN, also leads to CLOSED.200 + text/event-stream → open eventconnection lost → error event, wait retry ms, request againstatus ≠ 200, wrong type, or 204→ error event, no retryclose() from your codeCONNECTINGreadyState 0OPENreadyState 1CLOSEDreadyState 2 · terminal
EventSource readyState: when the browser retries and when it gives up. Three states. CONNECTING (readyState 0) becomes OPEN (1) when the server answers 200 with Content-Type text/event-stream. A dropped connection or network error moves OPEN back to CONNECTING after firing an error event; the browser waits the retry delay and requests again, sending Last-Event-ID if it has one. From CONNECTING, a response with a status other than 200, a wrong Content-Type, or HTTP 204 moves to CLOSED and the browser does not retry. Calling close() from your code, drawn here from OPEN, also leads to CLOSED.

What the figure makes explicit is the exit to CLOSED. Per the HTML standard, a response with any status other than 200, a different Content-Type, or an HTTP 204 fails the connection and the browser does not retry. That has two consequences. A 204 is a deliberate way for the server to say "stop reconnecting", useful for a stream that has genuinely ended. More dangerously, a 502 or 503 from a proxy in the middle of a deploy kills the client for good, and the only signal is an error event with readyState === EventSource.CLOSED. Production code needs a handler that recreates the object with its own backoff, as the resuming client later in this lesson does.

Two more behaviours surprise people. When the server ends the response normally (it calls res.end()), the browser treats that as a dropped connection and reconnects, so a client that has seen the final event should call close() itself, as the example does. And the default delay before reconnecting is browser-defined, typically a few seconds, with no exponential backoff and no jitter. The server can change it with retry:, and should, for reasons that return in the scaling section.

Authentication, headers and CORS

Native EventSource does not expose an arbitrary request-header API. It issues a GET with no body, so an application that relies on bearer tokens in an Authorization header cannot use it directly. The realistic options each carry a cost:

  • Cookie-based authentication. Same-origin it just works. Cross-origin you need new EventSource(url, { withCredentials: true }) on the client, and on the server Access-Control-Allow-Credentials: true with an explicit origin in Access-Control-Allow-Origin, since a wildcard is rejected for credentialed requests.
  • A short-lived stream token in the query string. Mint it from an authenticated endpoint, bind it to one user and one stream, and expire it in well under a minute. Query strings reach access logs and browser history, so they must be redacted or kept out of logs. Note the interaction with reconnection: the browser reconnects to the same URL, so a strictly single-use token breaks the first retry, while a reusable one is a leaked-URL risk. The window has to be chosen deliberately.
  • A fetch-based streaming client. fetch accepts headers and exposes the response body as a stream, so it can carry the Authorization header and a Last-Event-ID you control. You give up the built-in parser and reconnection and must reimplement both.
Fetch-based SSE client with Authorization, resume and jittered backoff
// Fetch-based SSE client: allows an Authorization header, but reconnection is now your job.
// Assumes the server terminates lines with \n (as the frame() helper above does).
function parseFrame(raw) {
  const ev = { type: 'message' };
  const data = [];
  for (const line of raw.split('\n')) {
    if (line === '' || line.startsWith(':')) continue;                 // blank or comment
    const c = line.indexOf(':');
    const field = c === -1 ? line : line.slice(0, c);
    let value = c === -1 ? '' : line.slice(c + 1);
    if (value.startsWith(' ')) value = value.slice(1);                 // one leading space is dropped
    if (field === 'data') data.push(value);
    else if (field === 'event') ev.type = value;
    else if (field === 'id' && !value.includes('\0')) ev.id = value;
    else if (field === 'retry' && /^\d+$/.test(value)) ev.retry = Number(value);
  }
  if (data.length) ev.data = data.join('\n');
  return ev;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function streamEvents(url, getToken, onEvent, signal) {
  let lastId = '';
  let retryMs = 3000;
  let failures = 0;

  while (!signal.aborted) {
    try {
      const res = await fetch(url, {
        signal,
        headers: {
          Accept: 'text/event-stream',
          Authorization: `Bearer ${await getToken()}`,
          ...(lastId && { 'Last-Event-ID': lastId }),
        },
      });
      if (res.status === 204 || res.status === 401 || res.status === 403) return;  // server says: stop
      if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);

      failures = 0;
      const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
      let buf = '';
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        buf += value;
        let end;
        while ((end = buf.indexOf('\n\n')) !== -1) {
          const ev = parseFrame(buf.slice(0, end));
          buf = buf.slice(end + 2);
          if (ev.retry) retryMs = ev.retry;
          if (ev.id !== undefined) lastId = ev.id;
          if (ev.data !== undefined) onEvent(ev);
        }
      }
    } catch (err) {
      if (signal.aborted) return;
    }
    // Exponential backoff with jitter; EventSource would simply wait retryMs every time.
    const delay = Math.min(30000, retryMs * 2 ** Math.min(failures++, 5));
    await sleep(delay / 2 + Math.random() * (delay / 2));
  }
}

Simplified: it assumes LF line endings and does not treat a stream that ends mid-event specially, matching what the native parser would discard. Libraries exist for this; the point is how little the protocol requires.

Whatever the mechanism, authentication is checked when the stream opens, and a stream can outlive the credential that authorised it. Decide what happens at token expiry: close the stream, let the client re-authenticate, and reconnect. Respond 401 or 403 to an unauthorised reconnect so the client lands in CLOSED and refreshes, rather than hammering the endpoint.

How Reconnection and Last-Event-ID Work

Reconnect is automatic; replay only exists if the backend builds it

After an interruption, the browser reconnects and, if it has received an event with an ID, sends it in a Last-Event-ID request header. That helps the server resume after a known point only if the backend can replay events. The protocol itself stores no history and promises no recovery: it hands the server a cursor and nothing else. The header is sent on automatic reconnects of an existing EventSource; a page reload creates a new object with no last ID, so a client that wants to resume across reloads has to persist its cursor and pass it explicitly, for instance as ?after=17.

SSE streaming and recovery: with a durable event storeOne response carries three event frames (ids 15, 16, 17). The connection then drops, the browser waits its retry delay and reconnects with Last-Event-ID 17. After the drop the browser reconnects with a Last-Event-ID header of 17. The SSE service queries the store for events after 17, gets 18 and 19, writes them as replayed frames, then continues with live event 20 on the same response.BrowserEventSourceEdge proxyterminates TLSSSE servicestream handlerEvent storedurable, ordered1. GET /builds/42/events2. id 15 · progress3. id 16 · progress4. id 17 · progress5. connection drops (deploy)6. events 18, 19 stored7. error · wait retry (~3 s)8. GET … Last-Event-ID: 179. events after 17?10. 18, 1911. id 18, 19 · replayed12. id 20 · live
SSE streaming and recovery: with a durable event store. After the drop the browser reconnects with a Last-Event-ID header of 17. The SSE service queries the store for events after 17, gets 18 and 19, writes them as replayed frames, then continues with live event 20 on the same response.

Warning. The header only says where the client stopped. The replay in messages 9 to 11 exists because the server kept the events and chose to look them up.

For a durable event stream, the server should validate the cursor, query events after it, emit the missed ones, then follow live updates. The first variant in the figure is that path; the second is what happens with a transient in-memory publisher, which cannot reconstruct events missed during downtime. Switch between them: the browser sends the same header in both, and only the backend decides whether it means anything.

Cursor design

A cursor is only useful if comparing it with an event is unambiguous. IDs such as build-42:17 scope the sequence to one stream and let the server reject a malformed or foreign cursor. The sequence must be ordered by commit, ideally gapless. A global auto-increment column does not give you that: two transactions can take values 19 and 20, commit in the opposite order, and a reader that has seen 20 and stored it as its cursor will never ask for 19. A per-stream counter assigned under the primary key avoids it, and as a bonus lets the client detect holes by checking that each ID is exactly one more than the last.

Event store schema, append, bounded replay and retention (PostgreSQL)
-- PostgreSQL. Per-stream, gapless, commit-ordered sequence numbers.
CREATE TABLE build_event (
  build_id   text        NOT NULL,
  seq        integer     NOT NULL,
  type       text        NOT NULL,
  payload    jsonb       NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (build_id, seq)
);

-- Append. The primary key rejects a concurrent writer that picked the same seq; that writer retries.
INSERT INTO build_event (build_id, seq, type, payload)
VALUES ('42',
        (SELECT COALESCE(MAX(seq), 0) + 1 FROM build_event WHERE build_id = '42'),
        'progress', '{"step":"tests","percent":65}');

-- Replay everything after the client's cursor (17), bounded so one reconnect cannot read the world.
SELECT seq, type, payload
  FROM build_event
 WHERE build_id = '42' AND seq > 17
 ORDER BY seq
 LIMIT 1000;

-- Retention, and the oldest cursor that can still be served without a gap.
DELETE FROM build_event WHERE created_at < now() - interval '1 day';
SELECT MIN(seq) AS oldest FROM build_event WHERE build_id = '42';

Runnable as written. For many writers per stream, serialise appends or use a per-stream counter row; the primary key guarantees no duplicate seq either way.

Replay without a gap

Replaying history and then going live looks like two simple steps, and the order of them is a correctness issue. This is the same check-then-wait race that bites long polling: if the handler reads the store first and subscribes to live events afterwards, any event committed between the two disappears.

Replay-to-live handoff: replay first, subscribe second (gap)The handler reads 18 and 19 from the store. While it is writing them out, event 20 is committed and published. Nobody is subscribed yet, so the broker drops it. The handler then subscribes and only sees event 21 onward. Event 20 is lost with no error anywhere.BrowserLast-Event-ID: 17Stream handlerone per connectionBrokerlive fan-outEvent storeseq is ordered1. GET, Last-Event-ID: 172. SELECT seq > 173. 18, 194. event 20 committed, published5. no subscriber: dropped6. id 18, 197. subscribe(build-42)8. live from 21 …
Replay-to-live handoff: replay first, subscribe second (gap). The handler reads 18 and 19 from the store. While it is writing them out, event 20 is committed and published. Nobody is subscribed yet, so the broker drops it. The handler then subscribes and only sees event 21 onward. Event 20 is lost with no error anywhere.

The sound order is to subscribe first and buffer, read history, flush history, drain the buffer, and then write live events directly. Events can now appear twice, once from the store and once from the buffer, so the handler skips any event whose sequence number is not greater than the last one it wrote. Duplicates are handled by a comparison; gaps cannot occur. The handler below also covers the other recovery case: if the cursor is older than what the store retains, it does not guess and sends an explicit resync event telling the client to fetch a snapshot.

Stream handler with validation, replay, dedupe and resync
// Express 4+. authenticate, canView, store and broker are your application's modules:
//   store.oldestSeq(buildId) -> number | null     store.after(buildId, seq) -> [{ seq, type, payload }]
//   broker.subscribe(topic, fn) -> unsubscribe     (fn receives { seq, type, payload })
// frame() is the helper from the minimal server above.
function readCursor(req, buildId) {
  const header = req.get('Last-Event-ID');                 // set by the browser on automatic reconnects
  if (header) {
    const m = /^build-(.+):(\d+)$/.exec(header);
    return m && m[1] === buildId ? Number(m[2]) : null;    // null = malformed or from another stream
  }
  const after = req.query.after;                           // the first connect after a page load
  if (after === undefined) return 0;
  return /^\d+$/.test(String(after)) ? Number(after) : null;
}

app.get('/builds/:id/events', async (req, res) => {
  const buildId = req.params.id;
  const user = await authenticate(req);                    // cookie session, validated at connect time
  if (!user) return res.sendStatus(401);
  if (!(await canView(user, buildId))) return res.sendStatus(403);

  const cursor = readCursor(req, buildId);
  if (cursor === null) return res.sendStatus(400);

  res.set({
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    'X-Accel-Buffering': 'no',
  });
  res.flushHeaders();

  let lastSent = cursor;
  let replaying = true;
  const pending = [];

  const send = (e) => {
    if (e.seq <= lastSent) return;                         // already delivered: drop the duplicate
    lastSent = e.seq;
    res.write(frame({ id: `build-${buildId}:${e.seq}`, event: e.type, data: e.payload }));
  };

  // 1. Subscribe BEFORE reading history, and hold live events until the replay is done.
  const unsubscribe = broker.subscribe(`build:${buildId}`, (e) => (replaying ? pending.push(e) : send(e)));
  const heartbeat = setInterval(() => res.write(': keepalive\n\n'), 15000);
  req.on('close', () => { unsubscribe(); clearInterval(heartbeat); });

  // 2. Cursor older than retention: tell the client to resync from a snapshot instead of guessing.
  const oldest = await store.oldestSeq(buildId);
  if (oldest !== null && cursor + 1 < oldest) {
    res.write(frame({ event: 'resync', data: { reason: 'cursor_expired', snapshot: `/builds/${buildId}` } }));
    return res.end();                                      // the client closes its EventSource on 'resync'
  }

  // 3. Replay history, then switch to live. The flag flip and the drain run synchronously after
  //    the last await, so no live event can slip between them.
  for (let batch = await store.after(buildId, lastSent); batch.length; batch = await store.after(buildId, lastSent)) {
    batch.forEach(send);
  }
  replaying = false;
  pending.forEach(send);                                   // send() skips anything the replay already covered
});

If the process that publishes to the broker dies between committing an event and publishing it, live subscribers never see that event. A sequence-checking client notices on the next event because the number jumped, and reopens from its last good cursor, which replays the hole from the store. Writing the event and its publication atomically is the job of the outbox pattern; the gap check is the cheap second line of defence.

Browser client that tracks sequence, dedupes, resyncs and recreates a closed stream
function watchBuild(buildId, apply) {
  let lastSeq = 0;
  let source = null;
  let timer = null;

  const close = () => { source?.close(); clearTimeout(timer); };

  const reopen = (delayMs = 0) => {
    close();
    timer = setTimeout(open, delayMs);
  };

  function open() {
    // The query parameter only matters for this first request; the browser's own
    // Last-Event-ID header takes over on automatic reconnects (the server prefers the header).
    source = new EventSource(`/builds/${buildId}/events?after=${lastSeq}`, { withCredentials: true });

    const onFrame = (event) => {
      const seq = Number(event.lastEventId.split(':')[1]);
      if (seq <= lastSeq) return;                          // replayed duplicate
      if (seq !== lastSeq + 1) return reopen();            // hole in the sequence: ask again from lastSeq
      lastSeq = seq;
      apply(event.type, JSON.parse(event.data));
      if (event.type === 'completed') close();
    };
    ['status', 'progress', 'completed'].forEach((t) => source.addEventListener(t, onFrame));

    source.addEventListener('resync', async () => {       // cursor fell out of server retention
      close();
      const snapshot = await (await fetch(`/builds/${buildId}`)).json();
      lastSeq = snapshot.lastSeq;
      apply('snapshot', snapshot);
      open();
    });

    source.onerror = () => {
      // CONNECTING: the browser is retrying by itself. CLOSED: it gave up, so we start over.
      if (source.readyState === EventSource.CLOSED) reopen(1000 + Math.random() * 4000);
    };
  }

  open();
  return close;
}

How long a cursor stays valid

Retention turns directly into a recovery guarantee. A stream emitting 5 events per second with 1,000 events kept can resume any client that was gone for about 200 seconds, including the reconnect delay. A ten-minute outage misses roughly 3,000 events (603 seconds at 5 per second is 3,015), so that client must resync from a snapshot. The calculator makes the trade explicit: more retention costs memory or storage and lengthens the replay burst a reconnecting client receives, and the burst arrives on top of live traffic.

5
1,000
20
3,000
Replay window against missed eventsAt 5 events per second, a client that is away for 20 seconds plus a 3 second reconnect delay misses about 115 events. The server keeps 1,000 events, which covers 200 seconds. The cursor is still inside the window, so the client can resume from replay.Events retained for replay: 1,000Events missed while away: 115resume
Replay window against missed events. At 5 events per second, a client that is away for 20 seconds plus a 3 second reconnect delay misses about 115 events. The server keeps 1,000 events, which covers 200 seconds. The cursor is still inside the window, so the client can resume from replay.
Missed events
115
23 s away at 5/s
Replay window
200 s
events kept ÷ rate
Outcome
Replay from cursor
Buffer memory
0.5 MB
at 500 B per event

Whether the fallback is "resync" or "resume", the product has to define it. For a progress bar a snapshot of the current state is enough. For an audit feed, the snapshot must be paired with the missed history, or the stream should be sourced from a log that is retained for as long as clients might be away.

Heartbeats and Proxy Buffering

Why a healthy 200 does not mean events are arriving, and how to measure it

Some intermediaries close connections that look idle. Sending a comment heartbeat such as : keepalive followed by a blank line at a suitable interval keeps data flowing, subject to what the infrastructure actually does. The comment is invisible to your handlers, costs 13 bytes, and resets idle timers that count bytes on the wire. Pick the interval from the shortest timeout on the path (a load balancer, a proxy's read timeout, a mobile carrier gateway), not from a default. Common defaults are around a minute, which is why 15 to 30 seconds is a typical interval, but check the real values for your environment. A second use is liveness: a write to a connection the client has abandoned eventually fails, and that is when the server learns it can clean up.

100,000
15
60
Heartbeat gap against the idle timeoutWith a heartbeat every 15 seconds and an idle timeout of 60 seconds somewhere on the path, survives with margin for one lost heartbeat. Each heartbeat is 13 bytes of payload.Longest silent gap: 15 sIdle timeout: 60 s
Heartbeat gap against the idle timeout. With a heartbeat every 15 seconds and an idle timeout of 60 seconds somewhere on the path, survives with margin for one lost heartbeat. Each heartbeat is 13 bytes of payload.

Survives with margin for one lost heartbeat.

Heartbeats per second
6,667
streams ÷ interval
Payload rate
86.7 KB/s
13 B each, headers excluded
Per stream per day
74.9 KB
Gap ÷ timeout
25 %
keep well under 100 %

The arithmetic is modest per connection and visible in aggregate. At the default 100,000 streams and a 15-second interval the fleet emits about 6,700 heartbeats per second, roughly 87 KB/s of payload and about 75 KB per stream per day, before TCP and TLS overhead, which usually dominates for a message this small. Aim for a gap of half the timeout or less so one delayed heartbeat does not cut the connection.

Buffering is the usual reason "streaming" does not stream

The server should flush the response when it sends events. If a proxy buffers the stream, events arrive in batches seconds after the application wrote them, and the status code, headers and server logs all look fine. The page appears to be polling slowly, which is the symptom to recognise.

Event delivery timing: streamed throughSix events are written by the server at seconds 1 to 6. Each frame is forwarded as soon as the server writes it, so every event reaches the browser about 50 ms after it was written. Average delay 0.05 s, worst delay 0.05 s. The delays are illustrative.Server writesBrowser receives1122334455660 s2 s4 s6 s8 s
Event delivery timing: streamed through. Six events are written by the server at seconds 1 to 6. Each frame is forwarded as soon as the server writes it, so every event reaches the browser about 50 ms after it was written. Average delay 0.05 s, worst delay 0.05 s. The delays are illustrative.
Average delay
0.05 s
Worst delay
0.05 s
HTTP status seen
200
looks healthy in every mode

Check each layer: reverse-proxy buffering settings, compression middleware, CDN support for streaming responses, idle timeouts, and gateway streaming behaviour. For nginx, disabling response buffering for the SSE location is a common requirement, and the response header X-Accel-Buffering: no does it per response. The configuration below is a starting point that should be reviewed against your version and topology.

nginx location for an SSE endpoint
upstream sse_backend { server 127.0.0.1:3000; }

server {
  listen 80;

  location /builds/ {
    proxy_pass http://sse_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection '';   # keep the upstream connection reusable, no "close"

    proxy_buffering off;              # pass bytes through as they arrive
    proxy_cache off;
    gzip off;                         # or make sure the compressor flushes after every write
    proxy_read_timeout 1h;            # time allowed between two reads from the upstream; default is 60 s
  }
}

Compression is the quieter offender. A gzip or brotli encoder holds bytes until a block fills, so an event stream compressed without flushing arrives all at once when the response ends. Either exclude text/event-stream from compression or make sure the compressor flushes after each event. At event sizes of tens of bytes the saving is small anyway.

Excluding event streams from the compression middleware (Express)
// Express with the "compression" middleware: skip event streams, or call res.flush() after each write.
import compression from 'compression';

app.use(compression({
  filter: (req, res) =>
    String(res.getHeader('Content-Type') ?? '').startsWith('text/event-stream')
      ? false
      : compression.filter(req, res),
}));

Do not assume that a successful 200 proves events are being delivered promptly. Measure the time from event creation to browser receipt. A reliable way is a synthetic client that connects through the same public edge as real users and records the delay between an event's created_at and its own receipt time, ideally on a host whose clock you trust. Comparing timestamps from arbitrary user devices to server clocks mixes in clock skew; use it for trends, not absolutes.

SSE Versus WebSockets and Long Polling

Choose by message direction, infrastructure, event rate and what must happen on reconnect

SSE is excellent for server-to-browser updates: notifications, logs, job progress, live scores. Consuming it in a browser takes a few lines, and reconnection is built in. It does not give you a native bidirectional message channel. A chat application can technically POST messages and receive them over SSE, and plenty do, but frequent two-way exchange is usually more natural on a WebSocket, where one connection carries both directions and acknowledgements can be part of the protocol. SSE avoids what WebSockets add (the upgrade handshake, a separate framing protocol, different infrastructure support) at the price of that single direction.

The same three events over long polling, SSE and WebSocketOver ten seconds, events happen at 2.0, 2.4 and 7.0 seconds. Long polling uses four HTTP requests: each one is held until an event completes it, and every completion is followed by a short gap before the next request is outstanding, which is when an event could be missed without a cursor. SSE keeps one HTTP response open for the whole period and writes the three events and occasional comment heartbeats onto it. A WebSocket also keeps one connection but carries traffic in both directions, here a client message at 5 seconds.0 s2 s4 s6 s8 s10 sLong polling4 requestsSSE1 response, server → clientWebSocket1 connection, both waysclient message● event happens: keepalive
The same three events over long polling, SSE and WebSocket. Over ten seconds, events happen at 2.0, 2.4 and 7.0 seconds. Long polling uses four HTTP requests: each one is held until an event completes it, and every completion is followed by a short gap before the next request is outstanding, which is when an event could be missed without a cursor. SSE keeps one HTTP response open for the whole period and writes the three events and occasional comment heartbeats onto it. A WebSocket also keeps one connection but carries traffic in both directions, here a client message at 5 seconds.

Long polling repeatedly completes and reopens HTTP requests; SSE keeps one response streaming for many events. In the figure, long polling needs four requests for three events and has short windows between requests where nothing is waiting, which is why it needs a cursor to be safe (see Long Polling). Each cycle also repeats request headers and cookies, commonly hundreds of bytes to a few kilobytes depending on your application, whereas an SSE frame adds a few tens of bytes around the payload. Polling a status every second is 86,400 requests per client per day; a stream replaces them with one connection and, at a 15 second heartbeat, 5,760 tiny comments. Neither mechanism provides exactly-once delivery by itself.

Connection limits are a browser and protocol property

Over HTTP/1.1, browsers cap concurrent connections per origin, conventionally at six, and in the common implementations the cap is shared across all tabs. Each open SSE stream occupies one of those slots for as long as it is open. Three tabs with two streams each consume all six, and further fetch calls to that origin queue behind them with no error; the application just seems frozen. HTTP/2 multiplexes streams onto one connection, so the browser limit stops mattering, but the server advertises a concurrent stream limit (100 is a common default) that still bounds you. SSE is a response stream over HTTP, so its behaviour depends on the negotiated HTTP version and on every intermediary's implementation, not on a universal fixed number.

3
2
Per-origin slots used by SSE streams3 tabs with 2 streams each want 6 concurrent streams to one origin. The budget is 6 (the usual HTTP/1.1 browser limit per origin). 6 are in use, 0 remain for ordinary requests, and 0 cannot start until a slot frees up.
Per-origin slots used by SSE streams. 3 tabs with 2 streams each want 6 concurrent streams to one origin. The budget is 6 (the usual HTTP/1.1 browser limit per origin). 6 are in use, 0 remain for ordinary requests, and 0 cannot start until a slot frees up.
Streams wanted
6
Slots left for API calls
0
of 6
Streams waiting
0
none
Fetch calls
stall

The practical rules follow. Serve SSE over HTTP/2 or HTTP/3 where you can. Multiplex logical channels over one stream per page, labelled with event: types, instead of opening one stream per widget. If you must stay on HTTP/1.1, put the stream on a separate hostname so it does not consume the slots your API calls need, and coordinate across tabs (for example with a shared worker or a leader tab) so five tabs do not hold five streams.

Choose from the actual requirements: message direction, infrastructure support, event frequency, reconnection behaviour and client environment. Pushing a notification to a page that never talks back favours SSE. A status that changes every few minutes favours plain polling. A collaborative editor favours WebSockets.

Backpressure and Slow Consumers

Bound every connection's queue and decide in advance what happens to a client that cannot keep up

A server may produce events faster than a browser or its network can consume them. Writing indefinitely into an unbounded buffer risks memory exhaustion, and the failure is nasty because it is caused by the weakest clients: a phone on a poor connection holds server memory proportional to how long it stays slow. Take 50 events per second at 400 bytes, which is 20 KB/s, to a client that drains 5 KB/s. The queue grows by 15 KB/s, about 54 MB per hour for that one client, and a thousand like it hold 54 GB. The calculator uses the same numbers by default.

50
400
5
256
Produced rate against client drain rateThe server produces 20 KB/s for this client, which can absorb 5 KB/s. The queue grows by 15 KB/s and reaches the 256 KB cap after 17.1 seconds; without a cap it would hold 54 MB after an hour.Produced: 20 KB/sClient drains: 5 KB/s
Produced rate against client drain rate. The server produces 20 KB/s for this client, which can absorb 5 KB/s. The queue grows by 15 KB/s and reaches the 256 KB cap after 17.1 seconds; without a cap it would hold 54 MB after an hour.
Queue growth
15 KB/s
Time to hit the cap
17.1 s
cap 256 KB
Uncapped after 1 h
54 MB
one slow client
1,000 such clients
54 GB/h
if every queue is unbounded

The streaming API tells you when to stop. In Node, res.write() returns false once the socket's buffer is past its high-water mark, and drain fires when it has emptied. Over HTTP/2 the same signal comes from per-stream flow control. Respect it, bound the per-connection queue, and define the policy when the bound is hit. There are four defensible choices:

  • Drop low-value updates such as a cursor position or a progress tick that the next one supersedes.
  • Coalesce to the latest state by key. For a stock-price display, sending only the latest price is acceptable.
  • Disconnect with a resync path: send a resync event, close, and let the client fetch a snapshot and reconnect from a fresh cursor.
  • Reduce emission frequency for that client, for example by batching to one frame per second.

What is not acceptable depends on the data. For an audit stream, dropping events silently is wrong; the right policies there are disconnect-and-resync from a durable log, or not dropping at all. The class below combines three of the policies: a bounded queue, latest-wins coalescing for keyed state, and a resync disconnect when the bound is exceeded.

Per-connection queue with backpressure, coalescing and disconnect
// One instance per connection. Bounded queue, latest-wins coalescing by key, disconnect with a resync path.
class SseClient {
  constructor(res, { maxQueue = 200 } = {}) {
    this.res = res;
    this.queue = [];
    this.maxQueue = maxQueue;
    this.blocked = false;
    this.closed = false;
    res.on('drain', () => { this.blocked = false; this.flush(); });   // the socket caught up
    res.on('close', () => { this.closed = true; this.queue.length = 0; });
  }

  push(evt) {                       // evt: { id, event, data, key? }
    if (this.closed) return;
    if (evt.key) {                  // state-like updates (a price, a gauge): keep only the newest per key
      const i = this.queue.findIndex((q) => q.key === evt.key);
      if (i !== -1) { this.queue[i] = evt; return; }
    }
    if (this.queue.length >= this.maxQueue) return this.disconnect('slow_consumer');
    this.queue.push(evt);
    this.flush();
  }

  flush() {
    while (!this.blocked && !this.closed && this.queue.length) {
      const ok = this.res.write(frame(this.queue.shift()));
      if (!ok) this.blocked = true; // write() returned false: stop until 'drain'
    }
  }

  disconnect(reason) {
    this.closed = true;
    this.queue.length = 0;
    this.res.write(frame({ event: 'resync', data: { reason } }));
    this.res.end();                 // the client fetches a snapshot, then reconnects from its cursor
  }
}

The general discipline is covered in Backpressure and, for refusing work outright, Load Shedding. The SSE-specific part is that every connection is its own small queue, so the policy has to live per client.

Payloads and exposure

SSE frames are text. Binary payloads must be encoded, and Base64 inflates them by a third (3 bytes become 4 characters), so a 1 MB image becomes about 1.33 MB on the wire. Large payloads should usually be referenced by URL rather than embedded in every event: send {"report":"/reports/881"} and let the client fetch it with a normal cacheable request. Authenticate every subscription and authorize the contents of events, not just the connection. Limit concurrent streams per user, and apply rate limiting to reconnections, since a client in a tight error loop is a self-inflicted load test.

Production Architecture and Observability

Broker fan-out, a catch-up path, deploys that clients can survive, and the metrics that expose failure

At scale, application instances consume updates from a broker and fan them out to the clients connected to them. A durable event store or a compacted state store supports reconnect catch-up. The instances stay stateless apart from their open connections, so any instance can serve any reconnect, and the broker is the piece that makes an event produced on one machine reach a client held by another. The Pub/Sub lesson covers the broker side.

SSE fan-out with a durable catch-up pathProducers append each event to a durable ordered event store and publish it to a broker. Several stateless SSE service instances subscribe to the broker for live events and fan them out to the connections they hold. When a client reconnects with Last-Event-ID, the instance it lands on reads the missed range from the event store before switching to live delivery. A stream-aware proxy or load balancer sits between the instances and the browsers.Producersbuilds, orders, metricsBrokerlive fan-outEvent storeordered, with retentionSSE instance Atopic → connectionsSSE instance Btopic → connectionsStream-aware edgeno buffering, long timeoutspublishappendlivecatch-up
SSE fan-out with a durable catch-up path. Live events travel through the broker; recovery reads come from the store. Instances keep only connection state in memory, so any of them can serve any reconnect, and the edge must be configured to pass streams without buffering. Each instance subscribes to the broker for the topics its connections need, and drains its streams when it is replaced.

Within an instance, keep a subscription registry keyed by authorized audience or topic, as in the sketch below. The topic name should encode the audience (tenant, user, resource), and authorization must happen before a connection is attached to it. Avoid the shortcut of broadcasting every event to every connection and filtering in the browser: the data has already left your trust boundary by then.

Per-instance topic registry
// Subscription registry for one instance: topic -> the connections that are allowed to see it.
const topics = new Map();           // topic -> Set<SseClient>

function attach(topic, client) {
  if (!topics.has(topic)) {
    topics.set(topic, new Set());
    broker.subscribe(topic, (evt) => {              // one broker subscription per topic per instance
      for (const c of topics.get(topic) ?? []) c.push(evt);
    });
  }
  topics.get(topic).add(client);
  client.res.on('close', () => topics.get(topic)?.delete(client));
}
// The topic name encodes the audience, e.g. "tenant:acme:build:42". Authorization happens before
// attach(), so an event is never written to a connection that may not read it.

Deploys and reconnection storms

Long-lived streams will be interrupted by routine infrastructure changes: deploys, autoscaling, proxy reloads, certificate rotation. Graceful deployment should close or drain streams in a way clients can recover from. If 50,000 clients lose their connection at once and each waits the same 3 seconds, the replacement instances see about 16,700 new connections per second, each doing authentication and possibly a replay query. Spreading the same clients over ten seconds brings that to about 5,000 per second. Randomising retry: per client and draining an instance's streams gradually both achieve that; the client-side backoff with jitter in the fetch example is the same idea. Rate limits and backoff protect what remains.

Draining streams on SIGTERM with per-client retry jitter
const clients = new Set();          // add in attach(), delete on 'close'

process.on('SIGTERM', () => {
  server.close();                                   // stop accepting new streams
  const drainMs = 20000;
  for (const c of clients) {
    setTimeout(() => c.res.end(), Math.random() * drainMs);   // spread reconnects over 20 s
  }
  setTimeout(() => process.exit(0), drainMs + 2000);
});

// On connect, give every client its own retry delay so a mass reconnect does not arrive in lockstep:
//   res.write(`retry: ${3000 + Math.floor(Math.random() * 10000)}\n\n`);

What to measure

A streaming system fails quietly, so instrument the properties that users feel:

  • Active streams per instance and per audience, and stream lifetime distribution; a spike of very short lifetimes means something is cutting connections.
  • Event-to-client latency, measured at the server write and by a synthetic client at the edge; the difference between the two is your buffering delay.
  • Bytes per connection and queue depth, to catch heavy topics and slow readers early.
  • Reconnection rate, with the Last-Event-ID age on each, and replay lag (how many events each reconnect had to replay).
  • Slow-consumer disconnects, authorization failures, and event gaps reported by clients that saw a non-contiguous sequence.

When SSE Is the Right Choice

Server-to-browser streams where the client reads and occasionally acts over plain HTTP

SSE fits when updates flow one way and the browser needs them promptly. A CI dashboard streaming build progress and log lines is the textbook case: the stream has a start and an end, ordering matters, and the only thing the client ever sends is the initial request. Notification feeds, live scoreboards, monitoring dashboards and analytics counters share the shape. In all of them one connection replaces a polling loop, and the work moves from the client asking repeatedly to the server holding state.

Online and offline indicators are a common case: a stream can carry "Ana came online" or "Ana went offline" to everyone watching her. SSE only transports those changes. Deciding what online means, including heartbeats, expiry and a grace period for reconnects, is the state model covered in Presence.

It fits less well where the client talks back constantly. Chat needs both directions at low latency, and while POST plus SSE works, you end up with two connection types and two error models; collaborative editing wants a bidirectional channel and is better served by WebSockets. And it is overkill where updates are rare: an order status that changes every few minutes is cheaper to poll than to hold a connection open for. The cost of SSE is connection state on your servers and a recovery story you must build, so use it when freshness justifies both.

The minimum credible first version is the handler and client in this lesson: framed events with IDs, a heartbeat, buffering disabled at the edge, a store that can replay from a cursor with an explicit resync fallback, bounded per-connection queues, and a client that dedupes and recreates a closed stream. The remaining work is scale and operation, not a different protocol. Related lessons: WebSockets, Long Polling, Pub/Sub.

Key Takeaways

Production rules to carry away from this lesson

  • SSE is a long-lived HTTP response, and replay exists only if the backend builds it: attach an id to every event, validate the Last-Event-ID cursor, replay from a durable store, and fall back to an explicit resync when the cursor is too old.
  • Send a comment heartbeat (typically every 15 to 30 seconds) sized from the shortest idle timeout on the path, and aim for a gap of half that timeout or less.
  • Disable buffering and compression for the stream at every hop (proxy buffering is the usual reason streaming does not stream) and measure event-to-client latency from an edge client.
  • Bound every connection's queue and decide in advance whether a slow client gets coalescing or a disconnect with a cursor.
  • Over HTTP/1.1 browsers allow about six connections per origin, shared across tabs. Prefer HTTP/2 or HTTP/3, multiplex channels on one stream per page, and coordinate tabs if you must stay on HTTP/1.1.
  • Spread reconnects after a deploy with per-client retry jitter and gradual draining, because 50,000 clients with the same 3-second wait become about 16,700 connections per second.