A Server-Sent Events stream looks simple from the client: create an EventSource, attach a handler, and the browser reconnects for you when the connection drops. That works in a demo. In a real browser the page is hidden for hours, the laptop sleeps on a train and wakes on a different network, the user opens the dashboard in five tabs, the session token expires at midnight and the page is put into the back/forward cache and restored. Each of those changes what the connection is doing, and most of them are invisible to EventSource itself.

The wire format and the basics of the API are covered in Server-Sent Events: one-way server-to-client streaming, and the server side of scale-out in scaling SSE in depth. This page follows the client through its life. The readyState and reconnection rules are quoted from the WHATWG HTML standard, and the throttling behaviour from Chrome's documentation, both checked on 2026-10-02; browsers differ in the details, so where behaviour is not standardized this page says so and gives a pattern that works regardless.

Advertisement

The three states and what moves between them

EventSource.readyState has three values. CONNECTING (0) means the connection has not been established yet, or was closed and the browser is reconnecting. OPEN (1) means it has a connection and is dispatching events. CLOSED (2) means it is not connected and is not trying to reconnect. Every story on this page is about the transitions.

A response with status 200 and Content-Type: text/event-stream moves CONNECTING to OPEN. A network error on an open stream moves it back to CONNECTING, and the browser waits for the reconnection time and tries again; the standard says that time is implementation-defined, probably a few seconds, that the server can set it with a retry: field, and that after a failed attempt a browser might add exponential backoff or wait for the operating system to report connectivity. A non-200 status, including 204 No Content, or a 200 with any other content type, fails the connection: the state goes to CLOSED and the browser never retries. Redirects are followed as for normal requests. Calling close() also goes to CLOSED.

Both reconnecting and failing fire the same error event, with no status code attached. The only way to tell them apart in onerror is to read readyState: CONNECTING means the browser is handling it, CLOSED means you must decide what to do. Many production bugs are clients that treat every error the same way, either building a second connection while the browser is already reconnecting, or doing nothing after a permanent failure and sitting dead forever.

EventSource readyState, and what moves itCONNECTING (0)request in flightOPEN (1)dispatching eventsCLOSED (2)no more reconnects200 + text/event-streamnetwork error: reconnectclose()non-200 (incl. 204) or wrong typefails the connectionYour code must decide whetherand when to build a new onePage events the browser does not handle for youvisibilitychangehidden: maybe closepagehide / pageshowclose, then reopenonlineforce reconnectsleep and wakeno event: clock jumpWatchdog: compare wall clock with last event timeOPEN does not mean alive
readyState transitions inside EventSource, and the page-level events outside it that a resilient client has to handle itself.

What the browser does for you, and what it does not

ConcernBuilt in?Notes
Reconnect after a network errorYesWaits for the reconnection time; the server can set it with retry:
Resume from the last eventYes, within one objectSends Last-Event-ID on its own reconnects; a new EventSource starts without it
Reconnect after a non-200 or wrong typeNoGoes to CLOSED; your code must decide
Detect a silently dead connectionNoA half-open TCP connection can stay OPEN with no events
Custom request headersNoOnly the withCredentials option; cookies, not Authorization headers
Pause while hidden, or close for bfcacheNoPage lifecycle handling is your job

The second row hides the most common resume bug. The Last-Event-ID header is sent only when the same EventSource object reconnects. If your code calls close() and creates a new object, for visibility, bfcache or a watchdog, the new request carries no ID and the server starts from now, silently skipping everything in between. Track the last ID yourself and send it as a query parameter on new connections, and have the server accept either the header or the parameter.

Advertisement

Hidden tabs and throttled timers

When a tab is hidden, browsers throttle its timers to save power. Chrome documents an intensive mode: for pages hidden more than five minutes, chained timers can be limited to waking once per minute, under conditions that include the page having been silent for at least 30 seconds. Other browsers have their own policies. The events of an open stream still arrive, but any logic that runs on setTimeout or setInterval runs late.

This breaks a common watchdog design. A client that counts heartbeat timer ticks, assuming each tick is ten seconds, miscounts badly when ticks arrive a minute apart. The robust design compares wall-clock times: record Date.now() on every event, including heartbeats, and when the timer eventually fires, ask whether the gap since the last event exceeds a threshold. A late timer then detects a dead connection late, which is acceptable in a hidden tab, rather than reaching a wrong conclusion. How heartbeats are chosen and tuned on both sides is covered in heartbeats and keepalive.

Then decide a policy for hidden pages. Keeping the stream open costs a server connection per hidden tab, which matters when users leave dashboards open all week. Closing it after a few minutes hidden and reopening on visibilitychange back to visible saves that, at the price of a resume burst when the user returns. Choose per feature: a chat notification stream should stay open; a live chart nobody can see should not.

bfcache, sleep and network changes

The back/forward cache keeps a page in memory after the user navigates away, so that going back is instant. Browsers have different rules about which open connections make a page ineligible, and those rules have changed over time. The pattern that is safe everywhere, recommended in web.dev's bfcache guidance for connections in general, is to close connections in pagehide and reopen them in pageshow when event.persisted is true, meaning the page was restored from the cache. Combined with your own last-ID tracking, the restored page resumes where it left off.

Sleep has no event at all. When a laptop sleeps, the TCP connection usually dies, but the browser may not notice on wake, especially if the network changed, and the stream can sit in OPEN with nothing arriving. Two signals help. The watchdog sees a large gap between the last event and now, because wall-clock time jumped. And on wake, a network change often fires online, which is a good moment to force a reconnect rather than waiting for the watchdog. Do not rely on navigator.onLine being accurate; it reports whether there is a network interface, not whether your server is reachable.

A resilient client

The class below combines the pieces: last-ID tracking with a query parameter, a wall-clock watchdog fed by data and heartbeat events, a closed-state retry with jittered exponential backoff, a hidden-page close policy, bfcache handling and an online-triggered reconnect.

class ResilientStream {
  constructor(url, onEvent, { staleMs = 45000, hiddenCloseMs = 120000 } = {}) { // null: never close
    Object.assign(this, { url, onEvent, staleMs, hiddenCloseMs });
    this.lastId = null; this.lastSeen = Date.now(); this.failures = 0;
    this.open();
    setInterval(() => this.watchdog(), 10000);           // may fire late in hidden tabs
    document.addEventListener("visibilitychange", () => this.onVisibility());
    window.addEventListener("pagehide", () => this.close());
    window.addEventListener("pageshow", (e) => { if (e.persisted) this.open(); });
    window.addEventListener("online", () => this.reopen());
  }
  open() {
    if (this.es) return;
    const u = new URL(this.url, location.href);
    if (this.lastId) u.searchParams.set("lastEventId", this.lastId); // a new object sends no header
    this.es = new EventSource(u, { withCredentials: true });
    this.es.onopen = () => { this.failures = 0; this.lastSeen = Date.now(); };
    this.es.onmessage = (e) => {
      this.lastSeen = Date.now();
      if (e.lastEventId) this.lastId = e.lastEventId;
      this.onEvent(e);
    };
    this.es.addEventListener("ping", () => { this.lastSeen = Date.now(); });
    this.es.onerror = () => {
      if (this.es.readyState === EventSource.CLOSED) this.retryLater(); // browser gave up
      // CONNECTING: the browser is already reconnecting; do nothing
    };
  }
  close() { if (this.es) { this.es.close(); this.es = null; } }
  reopen() { this.close(); this.open(); }
  retryLater() {
    this.close();
    const base = Math.min(60000, 1000 * 2 ** this.failures++);
    setTimeout(() => this.open(), base / 2 + Math.random() * base / 2); // jittered
  }
  watchdog() {
    if (this.es && Date.now() - this.lastSeen > this.staleMs) this.reopen();
  }
  onVisibility() {
    clearTimeout(this.hiddenTimer);
    if (document.visibilityState === "hidden" && this.hiddenCloseMs != null) {
      this.hiddenTimer = setTimeout(() => this.close(), this.hiddenCloseMs);
    } else if (!this.es) {
      this.open();                                      // resume from lastId
    }
  }
}

Two details are deliberate. The onerror handler does nothing in the CONNECTING state, so the client never fights the browser's own reconnect. And retryLater uses jitter, because when a server deploy returns 503 to fifty thousand clients at once, synchronized retries arrive as a second outage; the same reasoning for WebSockets is in WebSocket reconnection strategies. A production version would also stop retrying on statuses that mean 'go away', which needs a way to learn the status, discussed next.

Auth expiry and the missing status code

Because EventSource cannot set an Authorization header, streams are usually authenticated by cookie with withCredentials, or by a token in the URL, which ends up in proxy and server logs and should be short-lived if you use it. Either way, expiry shows up on a reconnect: the server answers 401, the connection fails, the state goes to CLOSED and the error event tells you nothing about why.

Practical answers: refresh the session before it expires, so reconnects carry a valid credential; on a CLOSED failure, call a cheap authenticated endpoint to learn whether the session is still valid, then reopen or send the user to sign in; or have the server send an explicit event such as event: reauth shortly before closing the stream, so the client can refresh first. The alternative is to drop EventSource and read the stream with fetch(), a ReadableStream reader and an AbortController: you get headers and status codes, and you take on parsing the event format and all of reconnection yourself.

Many tabs, one stream

Each tab with an EventSource holds its own connection. Over HTTP/1.1, browsers limit connections per origin to a small number, so five tabs of the same dashboard can exhaust the limit and stall other requests; HTTP/2 multiplexes streams over one connection and removes most of that pressure, but the server still holds one stream per tab. The fix is to elect one tab to hold the stream and share its events with the others. The Web Locks API makes the election simple: the tab that acquires a named lock opens the stream, and when it closes, the lock passes to another waiting tab, which opens the stream and resumes from its own last ID.

// One tab holds the lock and the connection; the others listen on a channel.
const channel = new BroadcastChannel("feed");
channel.onmessage = (e) => render(e.data);

navigator.locks.request("feed-leader", async () => {
  // Runs only in the tab that holds the lock; held until this promise settles.
  const stream = new ResilientStream("/events", (e) => {
    render(e.data);
    channel.postMessage(e.data);                        // fan out to other tabs
  }, { hiddenCloseMs: null });                          // the leader must not close when hidden
  await new Promise(() => {});                          // hold until the tab goes away
});

A SharedWorker can hold the connection instead, but it is not available in every mobile browser, so the lock pattern is the more portable choice. The leader must not apply the hidden-tab policy, or a hidden leader would starve a visible follower while still holding the lock. Followers must track the last event ID they saw, so that a new leader resumes without a gap.

What the server should do for the client

The client patterns above need help from the server. Send a comment or a small named event periodically; the standard suggests a comment every 15 seconds or so to keep proxies from timing out, and a named event such as ping also feeds the client watchdog, because comments are not dispatched to scripts. Set retry: deliberately. Give every event an ID that can be resumed from, and accept it as a header or a query parameter. Use 204 to tell a client to stop permanently, for example when a feature is turned off.

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

retry: 5000

: keep-alive comment every ~15 s, so proxies and the client watchdog see traffic
event: ping
data: {}

id: 81231
data: {"order": 42, "status": "shipped"}

Worked example: one dashboard, one day

  • 09:00 The user opens the order dashboard. The leader tab opens the stream; two more tabs follow via the channel.
  • 09:20 The single-tab admin page is hidden for a meeting; after two minutes it closes its stream.
  • 10:05 The user returns. visibilitychange reopens with lastEventId=81231, and the server replays the 40 missed events.
  • 12:30 The laptop sleeps on a train. The stream is OPEN but dead when it wakes on another network at 14:10; online fires and the client reconnects with the last ID.
  • 00:00 The session expires; the next reconnect gets 401 and CLOSED. The session endpoint confirms it, so the client shows sign-in instead of retrying.

Failure modes

  • Dead OPEN stream: no watchdog, so a half-open connection shows stale data for hours.
  • Gap on resume: a new EventSource without the last ID silently skips events.
  • Double connections: reconnecting in onerror while the browser is already reconnecting.
  • Permanent silence: a 401 or a 502 with an HTML error page closes the stream and nothing reopens it.
  • Synchronized retries: backoff without jitter turns a short server outage into a reconnect storm.

What to do next

  1. Log readyState in every onerror handler and count CLOSED failures separately from reconnects.
  2. Track the last event ID in your own code and send it as a query parameter on every new connection; make the server accept it.
  3. Replace any tick-counting watchdog with a wall-clock comparison fed by data and heartbeat events.
  4. Add pagehide/pageshow, visibilitychange and online handlers, and decide the hidden-tab policy per feature.
  5. Test the day above by hand: hide the tab, sleep the machine, switch networks and expire the session, and check for gaps or duplicates.
Key takeaway: EventSource reconnects by itself only after network errors on the same object; non-200 responses and wrong content types close it for good, both cases fire the same error event, and only readyState tells them apart. Everything else in a page's life, including hidden tabs with throttled timers, bfcache, sleep, network changes, auth expiry and many tabs, is the application's job. Track the last event ID yourself, watch wall-clock gaps rather than timer ticks, close and reopen around page lifecycle events, retry closed streams with jittered backoff, and share one stream across tabs.