Detecting EventSource Support and Falling Back Permalink to this section

Part of Browser Support & Polyfill Strategies, under SSE Protocol Fundamentals & Architecture.

Every current browser ships EventSource, so the classic feature check ('EventSource' in window) almost always passes. The failures that matter now are subtler: the API exists but the network makes it useless — a corporate proxy that buffers the stream, an antivirus product that holds the response, a captive portal, a WebView with quirks. A robust client detects working streaming at runtime, not just the presence of the API, and degrades through fetch-based streaming to polling when it has to. This guide builds that detection and fallback chain.

Symptom & Developer Intent Permalink to this section

  • A few enterprise customers report that live updates never appear, while everyone else is fine.
  • The stream connects (onopen fires) but events arrive only in large, delayed batches.
  • In some embedded WebViews or unusual runtimes, EventSource is undefined.
  • The team cannot tell from telemetry which users are affected.
  • Adding a polyfill did not help, because the API was never the problem.

The intent is a client that notices within seconds when streaming is not working, switches to the best available fallback, and reports which mode each session ended up in.

Root Cause Analysis Permalink to this section

There are three distinct failure classes, and they need different detection.

Three ways streaming fails in the field Stack of three failure classes: the API is missing, the connection fails outright, and the connection opens but events are buffered by an intermediary. Three ways streaming fails in the field API missing old WebView, exotic runtime feature check catches it Connection fails blocked path, 4xx/5xx error event catches it Opens, but buffered proxy, antivirus needs a first-event timeout
Only the first is detected by a feature check. The third is the most common in enterprise networks and needs a timing check.

The buffered case is the hardest because nothing looks wrong: the request succeeds with 200 text/event-stream, open fires, and the browser patiently waits for bytes the intermediary is holding. The only reliable detector is timing: the server sends an event immediately on connect, and if it has not arrived within a few seconds, the path is buffering.

Step-by-Step Resolution Permalink to this section

Step 1 — Make the server send something immediately Permalink to this section

res.write('retry: 3000\n\n');
res.write(`event: hello\ndata: ${JSON.stringify({ t: Date.now() })}\n\n`);   // first byte of real data

The hello event is the probe. Pad it with a comment of 2 KB for the first event only if you want to push past small fixed-size buffers — some intermediaries release data once a threshold is crossed.

Step 2 — Detect presence, then detect working delivery Permalink to this section

export async function openRealtime(url, handlers) {
  if (typeof window.EventSource !== 'function') return openFetchStream(url, handlers);   // API missing

  const es = new EventSource(url, { withCredentials: true });
  const ok = await new Promise((resolve) => {
    const timer = setTimeout(() => resolve(false), 5000);   // no hello in 5 s → treat as buffered
    es.addEventListener('hello', () => { clearTimeout(timer); resolve(true); }, { once: true });
    es.addEventListener('error', () => {
      if (es.readyState === EventSource.CLOSED) { clearTimeout(timer); resolve(false); }
    });
  });

  if (ok) { wire(es, handlers); report('eventsource'); return () => es.close(); }
  es.close();
  return openFetchStream(url, handlers);
}

Step 3 — Try fetch streaming, with the same probe Permalink to this section

A fetch-based stream reads the body incrementally, can send headers and sees the real status code. It helps when EventSource is missing or when an extension interferes with it, but it cannot defeat an intermediary that buffers the whole response. Apply the same first-event timeout:

async function openFetchStream(url, handlers) {
  const ac = new AbortController();
  try {
    const res = await fetch(url, { headers: { Accept: 'text/event-stream' }, credentials: 'include', signal: ac.signal });
    const got = await firstEventWithin(res.body, 5000, handlers);   // parse; resolve on 'hello'
    if (got) { report('fetch-stream'); return () => ac.abort(); }
  } catch { /* fall through */ }
  ac.abort();
  return openPolling(url.replace('/stream', '/poll'), handlers);
}

The parser is described in parsing SSE from a fetch ReadableStream.

Step 4 — Fall back to polling with a cursor Permalink to this section

function openPolling(url, handlers, intervalMs = 5000) {
  let cursor = '', stopped = false;
  (async function loop() {
    while (!stopped) {
      const r = await fetch(`${url}?after=${encodeURIComponent(cursor)}`, { credentials: 'include' });
      const { events, next } = await r.json();
      events.forEach((e) => handlers[e.type]?.(e.data));
      cursor = next;
      await new Promise((ok) => setTimeout(ok, intervalMs));
    }
  })();
  report('polling');
  return () => { stopped = true; };
}

The polling endpoint returns the same events as the stream after a cursor, so the application code is identical in every mode.

The fallback chain Flow from a feature check to EventSource with a first-event timeout, then fetch streaming with the same timeout, then cursor-based polling, with the chosen mode reported. The fallback chain Feature check EventSource? yes EventSource hello in 5 s? no Fetch stream hello in 5 s? no Polling cursor, 5 s chosen Report mode telemetry
Each step is tried only if the previous one failed to deliver the hello event in time. Most users never leave the first box.

Keep all three modes behind one interface that exposes the same handlers, the same connection status values and the same cursor semantics. The application should never branch on the mode; only the telemetry cares which one is active. Carry the cursor across mode switches, so an upgrade from polling to streaming resumes exactly where the last poll left off instead of replaying or skipping events.

Step 5 — Retry the better modes later Permalink to this section

Networks change: a laptop leaves the office VPN. Periodically (every 10–15 minutes) or on the online event, try upgrading from polling back to streaming, keeping polling running until the stream proves itself with a hello.

Validation & Monitoring Permalink to this section

Simulate each failure class: remove EventSource in a test (delete window.EventSource), block the stream path in a proxy to force errors, and put a buffering proxy (nginx with proxy_buffering on and a large buffer) in front to reproduce the silent case. Each should end in the expected mode within about ten seconds.

Sessions by final transport mode Bar chart of the share of sessions ending in EventSource, fetch streaming and polling for a business-to-business product. Sessions by final transport mode EventSource 96.8 % Fetch stream 0.4 % Polling 2.8 % share of sessions by final mode
Illustrative distribution for a B2B audience. The small polling share is exactly the customers who previously reported "live updates never work".

Test the upgrade path as deliberately as the downgrade: start in a buffering environment, remove the buffering, and confirm the client returns to streaming within the upgrade interval without duplicating or skipping events at the switch.

Report the final mode, the reason for each downgrade and the organisation or network (where known). Clusters of polling sessions from one customer are worth a conversation with their network team: an allow-list for your stream path usually restores streaming.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Do I still need an EventSource polyfill?

Rarely, for mainstream browsers. A fetch-based client covers the remaining runtimes and also adds headers and status codes, which is why it is the second step rather than a polyfill.

Why use a first-event timeout rather than waiting for onopen?

The open event fires when response headers arrive. A buffering intermediary forwards headers promptly and holds the body, so open fires while no events ever arrive.

How long should the timeout be?

Long enough for a slow mobile connection to deliver a small first event, and short enough that users do not notice — three to five seconds works well.

Can fetch streaming beat a buffering proxy?

No. If an intermediary buffers the whole response, any streaming technique over that path is affected. Only polling, or a different path, works until the network is fixed.