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) oreventsource(Firefox) Content-Type: text/event-streamin Response Headers (no charset suffix)- No
Cache-Control: max-ageorExpiresheaders - In the EventStream sub-pane, each row must show
data,event,idfields 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-streamwith no charset suffix andX-Accel-Buffering: noon every SSE endpoint — these two headers prevent the most common Safari and proxy failures. - Set
proxy_read_timeoutin Nginx to at least 3600s and send a: ping\n\ncomment 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
EventSourcein avisibilitychangelistener that forces reconnect on iOS tab activation — iOS Safari does not fireonerrorwhen 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.