Browser Support & Polyfill Strategies Permalink to this section
Part of SSE Protocol Fundamentals & Architecture.
Implementing reliable real-time updates requires addressing inconsistent native EventSource support across browsers, runtimes, and hostile network environments. Modern engines implement the WHATWG HTML Living Standard’s EventSource API natively, but legacy browsers, enterprise proxies, and some mobile environments silently break streaming connections or strip critical response headers. Production systems require deterministic fallback strategies rather than assuming best-case browser support.
EventSource Support Matrix Permalink to this section
The WHATWG HTML Living Standard defines EventSource in the “Server-sent events” section. Browser support has been stable for over a decade in most engines; the notable gap is Internet Explorer, which never shipped a native implementation, and the absence of support in some non-browser runtimes.
| Environment | Native EventSource | fetch+ReadableStream | Notes |
|---|---|---|---|
| Chrome 6+ | Yes | Yes (Chrome 43+) | Full spec compliance |
| Firefox 6+ | Yes | Yes (Firefox 65+) | Full spec compliance |
| Safari 5+ | Yes | Yes (Safari 14.1+) | Background tab throttling on iOS |
| Edge (Chromium) 79+ | Yes | Yes | Matches Chrome |
| Edge (EdgeHTML) 12–18 | Yes | Partial | Legacy; EOL |
| IE 11 | No | No | Use polyfill or fetch shim |
| IE 6–10 | No | No | XHR long-polling only |
| Deno | No native | Yes | Use fetch + TextDecoderStream |
| Cloudflare Workers | No | Yes | ReadableStream + TransformStream |
| Node.js 18+ (undici) | No | Yes | fetch() available globally |
| React Native | No | Partial | EventSource polyfill required |
Global EventSource availability sits at approximately 97% of web users as of 2026 per caniuse data. The remaining 3% requires a polyfill or alternative transport. For audience segments heavily skewed toward enterprise IT (IE 11 in locked-down Windows environments), plan for the polyfill path explicitly.
How EventSource Works Under the Hood Permalink to this section
EventSource opens a persistent HTTP/1.1 or HTTP/2 GET connection and reads the text/event-stream response body as a byte stream. The browser’s built-in parser processes the stream incrementally according to the WHATWG spec algorithm:
- Accumulate bytes from the response body.
- Split on
\n(or\r\n, or\r). - For each non-empty line, parse the field name and value.
- On a blank line, dispatch the buffered event via
dispatchEvent.
The key wire-level fields — as described in detail in Understanding the Event Stream Format — are:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
id: 42
event: price-update
data: {"symbol":"AAPL","price":189.34}
retry: 3000
: heartbeat
id: 43
data: {"symbol":"GOOG","price":172.11}
The browser’s reconnect behaviour — governed by retry: and Event ID & Retry Mechanism Design — sets the reconnect timer and sends Last-Event-ID as a request header on the next attempt. Any polyfill must replicate this behaviour precisely.
Detecting Support and Loading Polyfills Permalink to this section
Never bundle polyfills unconditionally. Use strict feature detection so capable clients pay no extra payload cost.
// transport.js — client entry point
async function initSSE(url, options = {}) {
if (typeof window !== 'undefined' && typeof window.EventSource !== 'undefined') {
// Native path: zero overhead
return createNativeEventSource(url, options);
}
if (typeof fetch !== 'undefined' && typeof ReadableStream !== 'undefined') {
// fetch+ReadableStream path: supports custom headers (unlike native EventSource)
return createFetchStream(url, options);
}
// Last resort: dynamic polyfill import, falls back to XHR internally
try {
const { EventSourcePolyfill } = await import(
/* webpackChunkName: "eventsource-polyfill" */ './eventsource-polyfill.js'
);
return createNativeEventSource(url, options, EventSourcePolyfill);
} catch (err) {
// Polyfill failed (CSP block, network error); degrade to XHR long-poll
console.error('[SSE] Polyfill load failed, falling back to long-polling:', err);
return createXHRLongPoll(url, options);
}
}
function createNativeEventSource(url, options, ESClass = EventSource) {
const es = new ESClass(url, { withCredentials: options.withCredentials ?? false });
es.addEventListener('message', options.onMessage);
es.addEventListener('error', options.onError);
return es;
}
Polyfill Options Compared Permalink to this section
| Library | Approach | Custom Headers | Size (min+gz) | IE 11 | Node.js |
|---|---|---|---|---|---|
event-source-polyfill (Remy Sharp) |
XHR streaming | Yes (via options) | ~5 KB | Yes | No |
eventsource (npm) |
Node http/XHR | Yes | ~8 KB | Yes | Yes |
launchdarkly-eventsource |
XHR + retry logic | Yes | ~12 KB | Yes | Yes |
| Custom fetch+ReadableStream | fetch API | Yes (full) | ~2 KB | No | Yes |
Native EventSource |
Browser built-in | No | 0 KB | No | No |
The event-source-polyfill package is the most common choice. Install and wire it up:
npm install event-source-polyfill
// Conditional polyfill import with tree-shaking support
import { NativeEventSource, EventSourcePolyfill } from 'event-source-polyfill';
const EventSource = NativeEventSource || EventSourcePolyfill;
// EventSourcePolyfill accepts an options.headers object
// which the native API does not support
const es = new EventSource('/api/stream', {
headers: {
'Authorization': `Bearer ${token}`, // custom auth header
},
heartbeatTimeout: 45000, // ms; kill & reconnect if no data arrives
withCredentials: true,
});
fetch + ReadableStream Fallback Implementation Permalink to this section
When EventSource is unavailable but fetch with streaming is present — the case in Cloudflare Workers, Deno, and modern Node.js — implement the SSE protocol manually using a ReadableStream reader. This approach also solves the native EventSource limitation of not supporting custom request headers.
// fetch-sse.js — a minimal fetch-based SSE consumer
async function* fetchSSE(url, options = {}) {
const controller = new AbortController();
const { signal } = controller;
// Allow callers to cancel via their own AbortSignal
if (options.signal) {
options.signal.addEventListener('abort', () => controller.abort());
}
const response = await fetch(url, {
method: options.method ?? 'GET',
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
...options.headers, // pass Authorization, X-Request-ID, etc.
},
credentials: options.credentials ?? 'same-origin',
signal,
});
if (!response.ok) {
throw new Error(`SSE connect failed: HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.startsWith('text/event-stream')) {
throw new Error(`Unexpected content-type: ${contentType}`);
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
let eventData = '';
let eventType = 'message';
let lastEventId = options.lastEventId ?? '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split('\n');
buffer = lines.pop(); // retain incomplete last line
for (const line of lines) {
if (line === '' || line === '\r') {
// Blank line = dispatch event
if (eventData) {
yield { type: eventType, data: eventData.replace(/\n$/, ''), lastEventId };
}
eventData = '';
eventType = 'message';
} else if (line.startsWith(':')) {
// Comment / heartbeat; ignore
} else {
const colonIdx = line.indexOf(':');
const field = colonIdx === -1 ? line : line.slice(0, colonIdx);
const val = colonIdx === -1 ? '' : line.slice(colonIdx + 2); // skip ': '
if (field === 'data') { eventData += val + '\n'; }
if (field === 'event') { eventType = val; }
if (field === 'id') { lastEventId = val; }
if (field === 'retry') {
const ms = parseInt(val, 10);
if (!isNaN(ms)) { /* store reconnect delay */ }
}
}
}
}
}
// Usage
for await (const event of fetchSSE('/api/events', {
headers: { 'Authorization': 'Bearer tok_abc123' },
lastEventId: localStorage.getItem('lastEventId') ?? '',
})) {
if (event.type === 'price-update') {
handlePriceUpdate(JSON.parse(event.data));
localStorage.setItem('lastEventId', event.lastEventId);
}
}
This generator handles multi-line data: fields (accumulated with \n), comment lines, custom event types, and lastEventId propagation — matching the WHATWG spec’s parsing algorithm. Reconnect logic wraps the generator call in a loop with exponential backoff.
XHR Long-Polling Last-Resort Fallback Permalink to this section
When neither native EventSource nor fetch streaming is available, implement XHR long-polling while preserving Last-Event-ID continuity so the server can resume the stream without replaying all events.
// xhr-longpoll.js
function createXHRLongPoll(url, { onMessage, onError, lastEventId = '' }) {
let active = true;
let retryDelay = 1000;
let currentId = lastEventId;
async function poll() {
while (active) {
const xhr = new XMLHttpRequest();
const pollUrl = currentId
? `${url}?lastEventId=${encodeURIComponent(currentId)}`
: url;
await new Promise((resolve) => {
xhr.open('GET', pollUrl);
xhr.setRequestHeader('Accept', 'text/event-stream');
xhr.setRequestHeader('Cache-Control', 'no-cache');
// Pass Last-Event-ID; server returns events since this ID
if (currentId) {
xhr.setRequestHeader('Last-Event-ID', currentId);
}
xhr.onload = () => {
if (xhr.status === 200) {
parseEventStreamChunk(xhr.responseText, onMessage, (id) => { currentId = id; });
retryDelay = 1000; // reset on success
} else {
onError(new Error(`HTTP ${xhr.status}`));
retryDelay = Math.min(retryDelay * 2, 30000);
}
resolve();
};
xhr.onerror = () => {
onError(new Error('XHR network error'));
retryDelay = Math.min(retryDelay * 2, 30000);
resolve();
};
xhr.send();
});
if (active) {
await new Promise(r => setTimeout(r, retryDelay));
}
}
}
poll();
return { close: () => { active = false; } };
}
The server’s SSE endpoint must handle both Last-Event-ID header and a ?lastEventId= query parameter to support this fallback, since XHR’s setRequestHeader may be blocked by CORS preflight in some configurations.
Edge Cases and Network Interference Permalink to this section
The most common production failures are caused by infrastructure between the client and server rather than browser bugs. See Security Headers for Event Streams for header requirements that also affect proxy behaviour.
Proxy and CDN Buffering Permalink to this section
HTTP proxies buffer responses until a size threshold or the connection closes, destroying the stream’s real-time properties. Nginx, Apache, AWS CloudFront, and Akamai all do this by default.
# nginx upstream config for SSE endpoint
location /api/events {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection ''; # disable keep-alive pooling; force streaming
proxy_buffering off; # critical: disable proxy response buffer
proxy_cache off;
proxy_read_timeout 3600s; # 1h; must exceed your heartbeat interval
add_header X-Accel-Buffering no; # Nginx upstream hint; propagate to CDN
add_header Cache-Control 'no-cache, no-store';
}
For Cloudflare CDN, set the response header X-Accel-Buffering: no from the origin. Cloudflare respects this header and passes bytes through without buffering.
iOS Safari Background Throttling Permalink to this section
Mobile Safari suspends network activity for background tabs after approximately 30 seconds. The EventSource connection is dropped without firing an error event. The connection resumes when the tab regains focus, but EventSource will have closed.
Mitigation: use the Page Visibility API to detect tab visibility changes and explicitly close/reopen the EventSource:
// Reconnect on visibility restore; avoids stale zombie connections
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (!es || es.readyState === EventSource.CLOSED) {
es = new EventSource(url);
}
} else {
// Optionally close proactively to save server-side resources
if (es) { es.close(); }
}
});
Corporate Firewall Header Stripping Permalink to this section
Some enterprise firewalls strip Transfer-Encoding: chunked or normalise HTTP/1.1 responses to buffered HTTP/1.0. When operating under these constraints:
- Serve SSE over HTTP/2 (avoids chunked encoding entirely; data is delivered as DATA frames).
- Implement regular comment heartbeats (
: keepalive\n\n) every 15 seconds to prevent timeout-based connection termination. - Send a 2 KB padding comment at the start of the response to clear IE’s XHR buffer threshold (IE required 256 bytes before triggering
onprogress).
Mitigation Checklist Permalink to this section
Performance and Scale Considerations Permalink to this section
Each EventSource connection is a persistent HTTP connection consuming a file descriptor and memory on the server. When browser support is patched via polyfill, the underlying transport (XHR or fetch) has the same cost per connection. The polyfill layer adds CPU overhead for JavaScript-side stream parsing.
| Consideration | Native EventSource | fetch Fallback | XHR Polyfill |
|---|---|---|---|
| Parser location | Browser C++ engine | JS event loop | JS event loop |
| Custom headers | No | Yes | Yes |
| HTTP/2 multiplexing | Yes (browser decides) | Yes | Limited |
| Memory per connection (server) | ~8–16 KB | ~8–16 KB | ~8–16 KB |
| CPU per 1K msg/s (client) | ~0.1% | ~0.5–1% | ~1–2% |
| Auto-reconnect | Built-in | Manual | Manual |
For Connection Pooling for SSE Servers and high-connection-count scenarios, the polyfill transport choice has no material impact on server-side memory — the cost is in the persistent TCP connection, not the protocol layer.
HTTP/2 vs HTTP/1.1 for Polyfill Paths Permalink to this section
HTTP/2 multiplexes multiple streams over a single TCP connection, which matters when you open multiple SSE channels per page (e.g., one per data subscription). With HTTP/1.1, browsers cap connections per origin at 6, limiting parallel SSE streams. HTTP/2 removes this cap.
Polyfills using fetch inherit HTTP/2 multiplexing automatically when the server and browser negotiate H2. XHR polyfills similarly benefit. Serve your SSE endpoints over HTTP/2 (TLS required) to avoid the 6-connection per-origin ceiling.
Memory Leak Prevention Permalink to this section
Polyfilled event sources accumulate closure references if not explicitly released. Always close the connection when the consuming component unmounts:
// React cleanup example
useEffect(() => {
const es = new EventSource('/api/stream');
es.onmessage = (e) => dispatch(parseEvent(e.data));
return () => {
es.close(); // prevents memory leak and dangling server connection
};
}, []);
For the fetch-based generator, call controller.abort() in the cleanup function to cancel the in-flight request. Failing to do so leaves an open TCP connection on the server until the socket timeout fires. See Preventing EventSource Memory Leaks in React for a detailed treatment.
Validation and Debugging Permalink to this section
curl Validation Permalink to this section
Confirm that the server is streaming correctly before testing the browser layer:
# Verify streaming; -N disables curl's own output buffering
curl -N -H "Accept: text/event-stream" \
-H "Cache-Control: no-cache" \
https://api.example.com/events
# Verify Last-Event-ID resumption
curl -N -H "Accept: text/event-stream" \
-H "Last-Event-ID: 42" \
https://api.example.com/events
# Check response headers for proxy buffering signals
curl -I https://api.example.com/events
# Look for: Content-Type: text/event-stream, X-Accel-Buffering: no
Browser DevTools Permalink to this section
- Network tab → filter by “EventStream” — Chrome DevTools shows a dedicated EventStream tab for SSE connections listing each parsed event with its
type,data,id, and timestamp. - Check response headers — confirm
Content-Type: text/event-streamand absence ofTransfer-Encodingbeing stripped. - Performance tab — record a 60-second profile while the stream is active; look for
parseEventcalls exceeding 5 ms, which indicates CPU-expensive JSON payloads per event. - Memory tab → heap snapshot — take snapshots before and after closing the
EventSource; verifyEventSourceinstances and associated closures are garbage-collected.
Feature-Detection Testing Permalink to this section
// Run in browser console to diagnose transport path
console.table({
nativeEventSource: typeof EventSource !== 'undefined',
fetch: typeof fetch !== 'undefined',
readableStream: typeof ReadableStream !== 'undefined',
textDecoderStream: typeof TextDecoderStream !== 'undefined',
abortController: typeof AbortController !== 'undefined',
});
Structured Logging for Polyfill Path Permalink to this section
const logger = {
transport: null,
connect(url, path) {
this.transport = path;
console.info('[SSE]', { url, transport: path, ts: Date.now() });
},
event(type, id) {
console.debug('[SSE] event', { type, id, transport: this.transport });
},
error(err, retryIn) {
console.warn('[SSE] error', { message: err.message, retryIn, transport: this.transport });
},
};
Log the transport path on connect so you can correlate support issues in your error tracker (Sentry, Datadog) with specific browser segments.
⚡ Production Directives
- Set
proxy_buffering offandX-Accel-Buffering: noon every reverse proxy and CDN layer in front of SSE endpoints — buffering silently destroys real-time delivery. - Send a server-side heartbeat comment (
: ping\n\n) every 15–30 seconds to prevent proxy timeouts and detect dead connections. - Always close
EventSourceinstances explicitly on component unmount or page unload; dangling connections exhaust server file descriptors. - Use dynamic import for polyfills so capable browsers pay zero bundle cost; verify with a
typeof EventSourcecheck, not user-agent sniffing. - Serve SSE endpoints over HTTP/2 to eliminate the 6-connection-per-origin ceiling that blocks multiple parallel SSE subscriptions.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why doesn't native EventSource support custom request headers?
The WHATWG specification defines EventSource as a simple GET request with no option for setting headers beyond what the browser automatically sends (cookies, Origin, etc.). This was a deliberate simplicity trade-off. For authentication, the spec assumes cookie-based credentials via withCredentials: true. If you need Authorization: Bearer or other custom headers, use the event-source-polyfill package or the fetch+ReadableStream approach, both of which accept arbitrary headers.
Can I use the fetch+ReadableStream approach as the primary implementation instead of EventSource?
Yes. The fetch+ReadableStream approach supports custom headers, works in non-browser environments (Cloudflare Workers, Deno, Node.js), and gives you full control over the reconnect logic. The trade-off is that you implement the WHATWG SSE parsing algorithm yourself (multi-line fields, comment handling, retry parsing) rather than relying on the browser's optimized C++ implementation. For environments where custom headers are required or where native EventSource is unavailable, fetch-based streaming is the recommended primary approach.
How do I test polyfill behaviour without an actual IE 11 browser?
Delete window.EventSource in the browser console before your page initializes (delete window.EventSource), which forces your feature-detection code to take the polyfill path. For automated testing, configure Playwright or Puppeteer to intercept and nullify EventSource on page load via page.addInitScript(() => { delete window.EventSource; }). For genuine IE 11 compatibility, use BrowserStack or a local Windows VM — emulation in Chrome DevTools does not faithfully reproduce IE 11's XHR streaming behaviour.
Does the polyfill correctly handle the retry: directive?
Most mature polyfills (like event-source-polyfill) parse the retry: field and store the delay for subsequent reconnection attempts, matching the spec behaviour. However, verify this explicitly: send a retry: 5000 field and disconnect the client; measure the actual reconnect delay in DevTools. Some older polyfill versions ignore retry: and use a hard-coded internal delay. The fetch-based custom implementation shown above requires you to store and apply the retry delay yourself in the reconnect loop.
Will EventSource reconnect automatically after a network interruption?
Native EventSource reconnects automatically after the delay specified by retry: (default 3 seconds per the WHATWG spec). It sends the Last-Event-ID header so the server can resume the stream. Most polyfills replicate this behaviour, but XHR long-polling fallbacks require manual reconnect loop implementation. In all cases, the reconnect only resumes from where it left off if the server tracks event IDs and supports resumption — see Event ID & Retry Mechanism Design for the server-side implementation.