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.

Browser Support Decision Tree and Fallback Chain Flowchart showing how a client checks for native EventSource support, falls back to a polyfill, then to fetch+ReadableStream, and finally to XHR long-polling. Client connects to SSE endpoint window.EventSource defined? Yes Native EventSource new EventSource(url) No fetch + ReadableStream? Yes fetch() fallback custom header support No XHR long-polling Last-Event-ID preserved Chrome 6+ Firefox 6+, Safari 5+ IE11, old Edge Deno, CF Workers
Client-side fallback decision tree: native EventSource → fetch+ReadableStream → XHR long-polling.

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:

  1. Accumulate bytes from the response body.
  2. Split on \n (or \r\n, or \r).
  3. For each non-empty line, parse the field name and value.
  4. 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

  1. 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.
  2. Check response headers — confirm Content-Type: text/event-stream and absence of Transfer-Encoding being stripped.
  3. Performance tab — record a 60-second profile while the stream is active; look for parseEvent calls exceeding 5 ms, which indicates CPU-expensive JSON payloads per event.
  4. Memory tab → heap snapshot — take snapshots before and after closing the EventSource; verify EventSource instances 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 off and X-Accel-Buffering: no on 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 EventSource instances 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 EventSource check, 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.

Deep Dives