skip to content

Your page's EventSource keeps firing its `error` event and the Network panel shows the request being re-issued, yet nothing in your code reopened the stream. What is EventSource doing, and when does it stop retrying on its own?

level: middleimportance: must knowfreq 58%

answer

  1. the browser owns the retry loop
  2. error does not mean dead
  3. read readyState inside the handler
  4. non-200 or wrong content type is fatal
  5. gaps and duplicates after every reconnect

basics

~20 s

EventSource reconnects by itself after a dropped connection, firing error each time. Check readyState: CONNECTING means it will retry, CLOSED means it gave up. It gives up on close(), or when the response is not 200 with Content-Type text/event-stream.

solid answer

~50 s

`EventSource` owns its own retry loop, which is the main thing it gives you over a hand-rolled stream. When the connection drops it fires `error`, sets `readyState` back to `CONNECTING` and re-issues the request after a delay the browser chooses — your code does nothing. So `error` is not a fatal signal, and the right move inside that handler is to read `es.readyState`: `CONNECTING` (0) means a retry is coming, `CLOSED` (2) means it is over. It stops permanently in two situations: you called `es.close()`, or the browser refused the response outright — a status other than 200, or a `Content-Type` that is not `text/event-stream`. Practically that means an expired session returning a 401 HTML page kills the stream for good, while a flaky network heals itself. Because each retry is a brand new HTTP request, design for gaps and duplicate delivery.

code

javascript · 13 lines
javascript
const es = new EventSource('/api/orders');

es.addEventListener('open', () => {
  console.log('connected; refetch the snapshot to close any gap');
});

es.addEventListener('error', () => {
  if (es.readyState === EventSource.CONNECTING) {
    console.log('dropped — the browser will retry by itself');
  } else if (es.readyState === EventSource.CLOSED) {
    console.log('gave up — build a new EventSource if you still want the feed');
  }
});

go deeper

for a junior

Know that EventSource reconnects on its own after a drop and that the error event fires each time. Be ready to name the three readyState values and say that close() is what stops it.

for a middle

Explain the retry loop precisely: error plus readyState CONNECTING means a retry is scheduled, CLOSED means it is over, and a response that is not 200 with Content-Type text/event-stream ends it permanently.

for a senior

Show the production reasoning — an expired session silently killing the feed, backoff you must supply once you rebuild the stream yourself, and idempotent handlers plus a snapshot refetch on every reopen.

for a principal

Own the resume contract end to end: whether the server can replay from a client's last id, how long it buffers, what a client does when the gap is too large, and what a fleet-wide reconnect storm costs after a deploy.

## Reconnection is the feature The reason to reach for `EventSource` at all, rather than reading a long-lived response yourself, is that the browser runs the reconnect loop for you. When the connection breaks — a laptop lid closes, a load balancer recycles a backend, a mobile network hands off — the browser fires an `error` event, puts `readyState` back to `CONNECTING`, waits a short interval, and issues the request again. Your application code contains no retry logic and no backoff timer. That convenience is exactly what surprises people the first time. Seeing repeated `error` events plus repeated requests in the Network panel looks like a bug in your code. It is not; it is the API working. ## error is a status update, not a verdict Because `error` covers both "dropped, retrying" and "finished, not retrying", the event alone tells you nothing. The state does: ```js es.addEventListener('error', () => { switch (es.readyState) { case EventSource.CONNECTING: // 0 — a retry is already scheduled showBanner('Reconnecting…'); break; case EventSource.CLOSED: // 2 — the browser has given up showBanner('Live updates unavailable'); break; } }); ``` The three states are `EventSource.CONNECTING` (0), `EventSource.OPEN` (1) and `EventSource.CLOSED` (2), and they are exposed as constants on the constructor so you never hard-code the numbers. A UI that flashes a red "connection lost" banner on every `error` will flicker constantly on a mobile network; gate it on the state, and ideally on the state persisting for a few seconds. ## The two ways it stops for good First, **you called `es.close()`**. `readyState` becomes `CLOSED` immediately and no further requests are made. This is the only clean shutdown, and there is no reopen — you construct a new `EventSource` if you want the feed back. Second, **the browser rejected the response**. If the response arrives with a status other than 200, or with a `Content-Type` that is not `text/event-stream`, the browser fails the connection: it fires `error`, sets `readyState` to `CLOSED`, and does not retry. That second rule has a sharp production consequence. A session that expires mid-stream typically means the next reconnect gets a 401 or a redirect to an HTML login page — neither is a 200 `text/event-stream`, so the stream dies permanently and silently while the rest of the app keeps working. Any real integration needs an `error` handler that notices `CLOSED` and either re-authenticates and builds a fresh `EventSource`, or tells the user the feed is stale. "It worked in dev because my session never expired" is the classic version of this bug. ## You do not control the retry delay from the client The reconnection interval is not a constructor option and there is no client-side API to change it. It is a property of the stream, chosen by the server side, with a browser default when the server says nothing. From the client's point of view, this means two things: you cannot implement client-side exponential backoff inside `EventSource`, and if you need a specific backoff policy you either negotiate it with the server or stop using `EventSource` and read the stream yourself. If the stream is permanently `CLOSED` and you rebuild it in a loop, **you** are now the retry loop, and you must add the backoff the browser would otherwise have provided. Rebuilding immediately on every failure turns one broken deploy into a self-inflicted request flood from every open tab. ## Design for gaps and duplicates Every reconnect is a brand-new HTTP request, and the browser automatically tells the server where the client left off using the last message id it saw. Whether the server can actually resume from that point is a server design question — many cannot, and simply start sending from now. So the client must be written for two realities. **Gaps**: messages produced while disconnected may never arrive, which is why a feed of *deltas* is fragile and a feed of *current state* (or a delta feed plus a "refetch the snapshot on reconnect" step) is robust. **Duplicates**: a resumed stream may re-send messages you already applied, so handlers should be idempotent — keyed upserts rather than `count += 1`. Both problems are invisible in development, where the connection never breaks, and both surface on the first real mobile user. A good habit is to treat `open` as a resync point: on every `open` after the first, refetch the authoritative snapshot once, then let the stream carry you forward. That single line of policy removes an entire class of drift bugs.

  • Your session expires while the stream is open. Walk me through what the user actually sees.
    Nothing, which is the problem. The connection drops, the browser retries, the retry gets a 401 or a redirect to an HTML login page, and because that is not a 200 `text/event-stream` the browser fails the connection permanently. `readyState` goes to `CLOSED`, one `error` fires, and the page simply stops updating while looking healthy. Handle `CLOSED` explicitly: re-authenticate and construct a new `EventSource`, or surface a stale-data warning.
  • If you rebuild the EventSource yourself after a permanent close, what must you add that the browser was doing for you?
    Backoff and a give-up rule. The built-in loop paces its retries; a hand-written `new EventSource()` inside an `error` handler retries instantly and, multiplied by every open tab, becomes a request flood against a server that is already failing. Add exponential delay with jitter, cap the attempts, and stop retrying entirely on an authentication failure until the user has re-authenticated.
  • How do you keep application state correct across a reconnect?
    Assume both gaps and duplicates. Make handlers idempotent — upsert by id rather than increment — so a replayed message is harmless, and treat every `open` after the first as a resync point where you refetch the authoritative snapshot once before applying further messages. A pure delta feed with no snapshot step drifts as soon as a real user rides a lift.

saying these in an interview costs you the question

  • Treating every error event as a fatal connection failure
  • Believing EventSource retries forever no matter what
  • Setting the reconnect interval from client-side JavaScript
  • Assuming no messages are missed while disconnected
  • Rebuilding the stream immediately in a loop with no backoff

context