Cross-Browser Compatibility for the EventSource API Permalink to this section

Part of Browser Support & Polyfill Strategies.

The EventSource API has broad native support today, but the gap between “it works” and “it works correctly under load, behind a proxy, on Safari, with the right headers, in an authenticated session” is where real-time applications break in production. This guide documents the exact support matrix, engine-specific failure modes, polyfill selection, and step-by-step fixes for every gap you will encounter when shipping SSE across all major browsers.

Symptom & Developer Intent Permalink to this section

You open your real-time dashboard in Safari and the stream never arrives — the Network tab shows the request as pending indefinitely with no EventStream type. In Firefox, streams connect but drop silently every 45 seconds despite a server-side retry: field. In Internet Explorer or older Edge (EdgeHTML), new EventSource(url) throws ReferenceError: EventSource is not defined. On iOS, background tab switching kills the stream and it never recovers.

The developer intent is to open a persistent text/event-stream connection that survives browser differences, reconnects automatically per event ID and retry design, and falls back gracefully where native support is absent — all without shipping separate code paths per browser.

Root Cause Analysis Permalink to this section

The Support Matrix Permalink to this section

Browser / Engine Native EventSource HTTP/2 multiplexing Custom headers Notes
Chrome 6+ / Chromium Yes (since 2010) Yes No (GET only) Reference implementation
Firefox 6+ Yes Yes No Minor retry timing variance
Safari 5+ / WebKit Yes Yes (Safari 9+) No 6-connection HTTP/1.1 cap per domain; strict MIME check
Edge (Chromium, 79+) Yes Yes No Identical to Chrome
Edge (EdgeHTML, ≤18) No No Requires polyfill
Internet Explorer 6–11 No No Requires polyfill
iOS Safari 5+ Yes Yes (iOS 9+) No Stream killed on background tab suspension
Android Chrome Yes Yes No Same as desktop Chrome
Samsung Internet 5+ Yes Yes No Based on Chromium

The EventSource constructor accepts only a URL and an EventSourceInit dictionary ({ withCredentials: true }). It always issues a GET request — you cannot set custom headers like Authorization. This is the single biggest constraint differentiating it from fetch-based streaming, which is covered in SSE Protocol Fundamentals & Architecture.

Why Engines Diverge Permalink to this section

MIME-type enforcement. The WHATWG spec requires the response Content-Type to be exactly text/event-stream. WebKit rejects responses with text/event-stream; charset=utf-8 in some versions — the charset parameter triggers a failure in older Safari (pre-14). Chrome and Firefox accept the charset parameter. Strip it on the server: emit Content-Type: text/event-stream with no suffix.

HTTP/1.1 connection caps. WebKit historically enforces a 6-connection-per-origin limit under HTTP/1.1. If your page opens 6 SSE streams to the same domain, the seventh request hangs forever. Under HTTP/2 this limit disappears (streams are multiplexed), but HTTP/2 must be negotiated; falling back to HTTP/1.1 behind a proxy reintroduces the cap.

Proxy and CDN buffering. Nginx, HAProxy, and most CDNs buffer upstream responses before forwarding them. A buffered SSE endpoint delivers all events in one burst when the connection closes — the opposite of streaming. This manifests as “stream works locally but hangs in staging behind a load balancer.” See Buffer Management & Chunked Transfer Encoding for the server-side configuration required.

Automatic retry on non-2xx. The spec says: on any network error, reconnect after the retry: interval. On a 4xx or 5xx response the browser still reconnects, flooding a broken endpoint. There is no native way to suppress this — it must be handled at the application layer.

CORS preflight. EventSource with withCredentials: true triggers a CORS preflight only for cross-origin requests. If the server returns a wildcard Access-Control-Allow-Origin: * header, credentialed requests fail with EventSource: CORS error in the console. You must reflect the exact origin. Details in Handling CORS in SSE Implementations.

iOS background suspension. When the user switches tabs or the app backgrounds on iOS, Safari suspends JavaScript timers and network activity. An open EventSource is dropped without firing onerror until the tab re-activates. The readyState stays 1 (OPEN) even though the underlying TCP connection is gone.

Step-by-Step Resolution Permalink to this section

1. Feature-Detect Before Instantiating Permalink to this section

Never assume EventSource exists. Check at runtime and route to a fallback transport before attempting connection.

function connectStream(url, handlers) {
  if (typeof EventSource === 'undefined') {
    // Polyfill not yet loaded or environment has no support
    console.warn('[SSE] EventSource unavailable, loading polyfill');
    loadPolyfill().then(() => connectStream(url, handlers));
    return;
  }
  const es = new EventSource(url, { withCredentials: true });
  es.onopen    = handlers.onOpen;
  es.onmessage = handlers.onMessage;
  es.onerror   = handlers.onError;
  return es;
}

async function loadPolyfill() {
  // event-source-polyfill: 8 kB gzipped, spec-compliant, supports custom headers
  const { EventSourcePolyfill } = await import('event-source-polyfill');
  window.EventSource = EventSourcePolyfill;
}

2. Fix Server Headers for Cross-Browser Compliance Permalink to this section

The three headers that break streams in the most browsers:

# nginx — apply to every SSE location block
location /api/stream {
  proxy_pass         http://backend;
  proxy_http_version 1.1;

  # Disable buffering — mandatory for streaming
  proxy_buffering    off;
  proxy_cache        off;

  # Keep the upstream connection alive
  proxy_set_header   Connection '';
  proxy_read_timeout 3600s;   # 1 hour; set to your longest expected stream

  # Strip charset from Content-Type (WebKit compat)
  # The upstream must emit: Content-Type: text/event-stream
  # Do NOT add charset here

  # Prevent any caching layer from storing the stream
  add_header Cache-Control  'no-store' always;
  add_header X-Accel-Buffering 'no' always;   # disables Nginx's internal buffer
}

On the application side (Node.js example):

// Express — emit correct headers before any data
app.get('/api/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');   // no charset suffix
  res.setHeader('Cache-Control', 'no-store');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');             // for Nginx upstreams
  res.flushHeaders();                                    // send headers immediately

  // Send a comment every 20s to defeat proxy timeouts
  const heartbeat = setInterval(() => res.write(': ping\n\n'), 20000);
  req.on('close', () => clearInterval(heartbeat));
});

3. Work Around WebKit’s Per-Domain Connection Limit Permalink to this section

Under HTTP/1.1, cap concurrent SSE connections per origin to 4 (safe margin below WebKit’s 6). Under HTTP/2 this is unnecessary, but the guard does no harm.

const MAX_CONCURRENT = 4;
let activeStreams = 0;
const pendingQueue = [];

function safeConnect(url, handlers) {
  if (activeStreams >= MAX_CONCURRENT) {
    pendingQueue.push({ url, handlers });
    return null;
  }
  activeStreams++;
  const es = new EventSource(url, { withCredentials: true });

  es.onopen = () => handlers.onOpen?.(es);

  es.onerror = () => {
    release();
    handlers.onError?.(es);
  };

  // EventSource has no 'close' event; wrap close() to release the slot
  const nativeClose = es.close.bind(es);
  es.close = () => { nativeClose(); release(); };

  function release() {
    activeStreams--;
    if (pendingQueue.length > 0 && activeStreams < MAX_CONCURRENT) {
      const next = pendingQueue.shift();
      safeConnect(next.url, next.handlers);
    }
  }
  return es;
}

4. Handle iOS Background Tab Suspension Permalink to this section

Use the Page Visibility API to detect suspension and force reconnect on resume. The full pattern is documented in Using the Page Visibility API to Pause Event Streams, but the core is:

let es = null;

function connect(url) {
  es?.close();
  es = new EventSource(url, { withCredentials: true });
  es.onmessage = handleMessage;
  es.onerror   = handleError;
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    // iOS may have silently killed the stream; reconnect unconditionally
    connect('/api/stream');
  } else {
    // Optionally close proactively to free server resources
    // (tradeoff: adds reconnect latency on return)
    // es?.close();
  }
});

connect('/api/stream');

5. Suppress Infinite Retry on Fatal Status Codes Permalink to this section

The browser auto-reconnects on any error. A 401 or 404 will trigger a reconnect loop every retry: ms. Detect via a parallel fetch before opening the stream:

async function openAuthenticatedStream(url) {
  // Probe the endpoint once with fetch; check status before committing to SSE
  const probe = await fetch(url, {
    method: 'GET',
    credentials: 'include',
    headers: { Accept: 'text/event-stream' },
  });
  if (probe.status === 401) { redirectToLogin(); return; }
  if (!probe.ok)            { reportError(probe.status); return; }

  // Connection is valid — now open EventSource (browser handles retry)
  const es = new EventSource(url, { withCredentials: true });
  es.onerror = (e) => {
    if (es.readyState === EventSource.CLOSED) {
      // Permanent close; re-probe before reconnecting
      openAuthenticatedStream(url);
    }
    // readyState === CONNECTING means auto-retry is already in progress
  };
  return es;
}

6. Persist Last-Event-ID Across Page Reloads Permalink to this section

Store lastEventId to survive hard refreshes and polyfill environments that do not send Last-Event-ID automatically:

const STORAGE_KEY = 'sse_last_event_id';

function buildUrl(base) {
  const url = new URL(base, location.origin);
  const saved = sessionStorage.getItem(STORAGE_KEY);
  if (saved) url.searchParams.set('lastEventId', saved);  // server reads this
  return url.toString();
}

function handleMessage(e) {
  if (e.lastEventId) sessionStorage.setItem(STORAGE_KEY, e.lastEventId);
  processEvent(e.data);
}

The server must read lastEventId from the query string (or the Last-Event-ID request header, sent natively by compliant browsers) and replay missed events. See Event ID & Retry Mechanism Design for the server-side implementation.

Validation & Monitoring Permalink to this section

DevTools check. Open the Network tab, filter by Fetch/XHR or type EventStream. The request must show:

  • Status 200
  • Type EventStream (Chrome/Edge) or eventsource (Firefox)
  • Content-Type: text/event-stream in Response Headers (no charset suffix)
  • No Cache-Control: max-age or Expires headers
  • In the EventStream sub-pane, each row must show data, event, id fields per message

Cross-browser automated test (Playwright):

// playwright.spec.js — run with: npx playwright test --project=chromium,firefox,webkit
import { test, expect } from '@playwright/test';

test('SSE stream delivers at least 3 events in all browsers', async ({ page }) => {
  const events = [];
  await page.route('/api/stream', route => route.continue());

  await page.goto('/dashboard');
  await page.evaluate(() => {
    window.__sseEvents = [];
    const es = new EventSource('/api/stream');
    es.onmessage = (e) => window.__sseEvents.push(e.data);
  });

  await page.waitForFunction(() => window.__sseEvents.length >= 3, { timeout: 10000 });
  const collected = await page.evaluate(() => window.__sseEvents);
  expect(collected.length).toBeGreaterThanOrEqual(3);
});

curl smoke test for correct headers:

curl -v --no-buffer \
  -H "Accept: text/event-stream" \
  -H "Cookie: session=<token>" \
  https://your-domain.com/api/stream 2>&1 | head -40
# Look for: Content-Type: text/event-stream
# Must NOT see: Transfer-Encoding lines delayed, or a complete response body

Verification Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Does EventSource work in Internet Explorer?

No. IE 6–11 have no native EventSource implementation. Use the event-source-polyfill package (npm), which implements the full WHATWG spec via XHR streaming in environments where the native API is absent. Load it dynamically only after feature detection fails to avoid adding ~8 kB gzipped to the main bundle for browsers that don't need it.

Why does my stream work in Chrome but hang in Safari?

The most common cause is the charset suffix on Content-Type. If your server emits text/event-stream; charset=utf-8, older Safari versions (pre-14) reject the stream. A secondary cause is hitting the 6-connection HTTP/1.1 per-domain limit. Check both: inspect the exact Content-Type response header and count concurrent SSE connections to the same origin.

Can I send custom Authorization headers with EventSource?

No. The EventSource API only accepts withCredentials (for cookies and TLS client certificates). If you need token-based auth, pass the token as a URL query parameter (/api/stream?token=…) or use a cookie. For environments requiring request headers (e.g. Bearer tokens without cookie support), replace EventSource with a fetch-based ReadableStream consumer, which allows arbitrary headers. See Authenticating SSE Streams with Tokens & Cookies for both patterns.

How do I stop the browser reconnecting after a 401?

You cannot prevent the browser's built-in reconnect from the onerror handler alone. The cleanest approach: probe the endpoint with fetch before opening EventSource, handle non-2xx there, and only create the EventSource after a successful probe. Alternatively, have the server send a retry: 0 field followed by closing the connection — but be aware this still retriggers a reconnect loop. The probe pattern is the most reliable.

Does HTTP/2 remove the connection-per-domain limit?

Yes. HTTP/2 multiplexes all streams over a single TCP connection, so the 6-connection per-origin cap does not apply. However, the limit only disappears when HTTP/2 is actually negotiated end-to-end. If a reverse proxy downgrades the upstream connection to HTTP/1.1 (common with older Nginx configurations using proxy_http_version 1.0), the cap reapplies between the proxy and origin. Verify with curl --http2 -v https://your-domain.com/api/stream — the response protocol must be h2.

⚡ Production Directives

  • Emit Content-Type: text/event-stream with no charset suffix and X-Accel-Buffering: no on every SSE endpoint — these two headers prevent the most common Safari and proxy failures.
  • Set proxy_read_timeout in Nginx to at least 3600s and send a : ping\n\n comment every 20 seconds to defeat intermediate proxy idle timeouts.
  • Cap concurrent SSE connections per origin at 4 with a client-side queue when HTTP/2 is not guaranteed end-to-end.
  • Wrap EventSource in a visibilitychange listener that forces reconnect on iOS tab activation — iOS Safari does not fire onerror when it suspends a stream.
  • Load polyfills dynamically, gated on feature detection, to keep the main bundle clean for the 99% of users on modern browsers.