Loading content...
Server-Sent Events offer elegant server-to-client streaming, but their value depends on reliable browser support across your user base. Understanding browser compatibility, implementing fallback strategies, and handling edge cases ensures your real-time features work for everyone—not just users with modern browsers on pristine networks.\n\nBrowser support for SSE is generally excellent in modern environments, but production applications must account for legacy browsers, mobile constraints, enterprise environments, and degraded network conditions.
By the end of this page, you will understand the complete browser support landscape for SSE, how to implement polyfills for legacy browsers, mobile-specific considerations that affect SSE reliability, cross-browser testing strategies, and graceful degradation patterns for maximum compatibility.
The EventSource API is part of the HTML Living Standard, with mature support across all modern browsers. Understanding the support matrix and edge cases helps you plan compatibility strategies.\n\nComprehensive Support Matrix
| Browser | Version | Support Status | Notes |
|---|---|---|---|
| Chrome | 6+ | ✅ Full | Stable since 2010. Includes Chrome for Android. |
| Firefox | 6+ | ✅ Full | Stable since 2011. Includes Firefox for Android. |
| Safari | 5+ | ✅ Full | Includes iOS Safari since iOS 4.2. |
| Edge (Chromium) | 79+ | ✅ Full | All Chromium-based Edge versions. |
| Edge (EdgeHTML) | No | ❌ None | Legacy Edge never implemented SSE. |
| Internet Explorer | No | ❌ None | Never supported. Requires polyfill. |
| Opera | 11+ | ✅ Full | Long-standing support. |
| Samsung Internet | 4+ | ✅ Full | Follows Chromium base. |
| UC Browser | Varies | ⚠️ Partial | Check specific versions. |
Feature Detection and Graceful Degradation\n\nNever assume EventSource is available. Always feature-detect before use:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Simple feature detectionfunction supportsSSE() { return 'EventSource' in window;} // More robust check including constructorfunction hasFullSSESupport() { if (!('EventSource' in window)) return false; try { // Verify constructor exists and is a function const EventSourceConstructor = window.EventSource; return typeof EventSourceConstructor === 'function'; } catch (e) { return false; }} // Usage with fallback strategyfunction initRealTimeConnection() { if (hasFullSSESupport()) { return initSSEConnection(); } else if (hasWebSocketSupport()) { return initWebSocketConnection(); } else { return initPollingFallback(); }} // Comprehensive feature detection moduleconst RealTimeCapabilities = { sse: 'EventSource' in window, websocket: 'WebSocket' in window, fetch: 'fetch' in window, streams: 'ReadableStream' in window, getBestTransport() { if (this.sse) return 'sse'; if (this.websocket) return 'websocket'; if (this.fetch) return 'long-polling'; return 'short-polling'; }, report() { console.log('Real-time capabilities:', { sse: this.sse, websocket: this.websocket, fetch: this.fetch, streams: this.streams, recommended: this.getBestTransport() }); }};EdgeHTML (pre-Chromium Edge, versions 12-18) never supported EventSource despite supporting WebSocket. If your analytics show EdgeHTML users, you need a fallback strategy—even though those browsers are technically 'modern' in other respects. Microsoft ended support for EdgeHTML in March 2021, but enterprise environments may still run it.
When native EventSource isn't available, polyfills can provide compatible functionality. Understanding the options and their trade-offs helps you choose the right approach for your user base.\n\nPolyfill Options Comparison
| Library | Size | Approach | Best For |
|---|---|---|---|
| event-source-polyfill | ~5KB min | XHR-based streaming | Maximum compatibility, header support |
| eventsource.js | ~3KB min | XHR streaming | Lightweight needs |
| @microsoft/fetch-event-source | ~4KB min | Fetch API streaming | Modern browsers, custom headers |
| Custom XHR implementation | ~1-2KB | Basic XHR polling | Minimal footprint, controlled fallback |
event-source-polyfill: Production-Ready Solution\n\nThe most popular polyfill provides feature parity with native EventSource while adding crucial capabilities like custom header support:
12345678910111213141516171819202122232425262728
// npm install event-source-polyfill import { EventSourcePolyfill } from 'event-source-polyfill'; // Use polyfill only when neededconst EventSourceImpl = window.EventSource || EventSourcePolyfill; // Basic usage - same API as nativeconst events = new EventSourceImpl('/api/events'); events.onmessage = (event) => { console.log('Received:', event.data);}; events.addEventListener('notification', (event) => { const data = JSON.parse(event.data); showNotification(data);}); // Polyfill-specific: custom headers!const authenticatedEvents = new EventSourcePolyfill('/api/events', { headers: { 'Authorization': 'Bearer ' + getToken(), 'X-Custom-Header': 'value' }}); // Works in IE11, Edge Legacy, and other non-supporting browsersDon't load polyfills unconditionally—they add bundle size and initialization cost for browsers that don't need them. Use dynamic imports: if (!window.EventSource) { await import('event-source-polyfill'); }. This keeps your main bundle lean for modern browsers while supporting legacy users.
Mobile browsers present unique challenges for persistent connections like SSE. Understanding these constraints helps you build resilient mobile real-time experiences.\n\nMobile-Specific Challenges
Platform-Specific Behavior
| Platform | Background Behavior | Reconnection | Best Practice |
|---|---|---|---|
| iOS Safari | Suspended after ~30s background | Automatic on foreground | Use Page Visibility API to detect |
| iOS Chrome | Follows system WebKit behavior | Automatic on foreground | Same as Safari on iOS |
| Android Chrome | May throttle after 5min background | Usually maintains connection | Send keepalives every 25s |
| Samsung Internet | Similar to Chrome | Similar to Chrome | Test on actual devices |
| PWA (installed) | Better background handling | May persist longer | Consider service worker approach |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
class MobileAwareSSE { constructor(url, options = {}) { this.url = url; this.options = options; this.eventSource = null; this.lastEventId = null; this.handlers = new Map(); this.isVisible = !document.hidden; this.setupVisibilityHandler(); this.connect(); } setupVisibilityHandler() { document.addEventListener('visibilitychange', () => { const wasVisible = this.isVisible; this.isVisible = !document.hidden; if (this.isVisible && !wasVisible) { // App came to foreground console.log('App foregrounded, checking connection...'); this.ensureConnected(); } else if (!this.isVisible && wasVisible) { // App going to background console.log('App backgrounded'); // Optionally close connection to save battery // this.disconnect(); } }); // Network change detection window.addEventListener('online', () => { console.log('Network restored, reconnecting...'); this.reconnect(); }); window.addEventListener('offline', () => { console.log('Network lost'); // EventSource will handle this, but we can update UI this.notifyConnectionStatus('offline'); }); } connect() { const url = this.lastEventId ? `${this.url}?lastEventId=${this.lastEventId}` : this.url; this.eventSource = new EventSource(url); this.eventSource.onopen = () => { this.notifyConnectionStatus('connected'); }; this.eventSource.onmessage = (event) => { this.lastEventId = event.lastEventId; this.handlers.get('message')?.(event); }; this.eventSource.onerror = (error) => { if (this.eventSource.readyState === EventSource.CLOSED) { this.notifyConnectionStatus('disconnected'); } else { this.notifyConnectionStatus('reconnecting'); } }; } ensureConnected() { if (!this.eventSource || this.eventSource.readyState === EventSource.CLOSED) { this.connect(); } } reconnect() { this.disconnect(); // Small delay to let network stabilize setTimeout(() => this.connect(), 1000); } disconnect() { if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } } on(event, handler) { this.handlers.set(event, handler); if (this.eventSource && event !== 'message') { this.eventSource.addEventListener(event, (e) => { this.lastEventId = e.lastEventId; handler(e); }); } } notifyConnectionStatus(status) { this.handlers.get('connectionStatus')?.(status); }} // Usageconst sse = new MobileAwareSSE('/api/events'); sse.on('message', (event) => { console.log('Message:', event.data);}); sse.on('connectionStatus', (status) => { updateConnectionIndicator(status);});For mobile apps, consider implementing connection throttling when battery is low. The Battery Status API (where available) can inform decisions about connection aggressiveness. Some apps disconnect SSE entirely in low-battery states, showing cached data with a refresh button instead.
Testing SSE implementations across browsers requires systematic approaches to verify behavior under various conditions. A comprehensive testing strategy ensures your real-time features work reliably for all users.\n\nTesting Matrix Dimensions
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
// tests/sse.spec.jsimport { test, expect } from '@playwright/test'; test.describe('SSE Connection', () => { test('establishes connection and receives messages', async ({ page }) => { // Navigate and wait for SSE endpoint const sseResponse = page.waitForResponse( response => response.url().includes('/api/events') ); await page.goto('/dashboard'); const response = await sseResponse; expect(response.status()).toBe(200); expect(response.headers()['content-type']).toContain('text/event-stream'); // Verify message received in UI await expect(page.locator('.connection-status')).toHaveText('Connected'); // Trigger server event (via API or test endpoint) await fetch('/api/test/trigger-event', { method: 'POST' }); // Verify UI updated await expect(page.locator('.latest-message')).toBeVisible(); }); test('handles reconnection after server disconnect', async ({ page }) => { await page.goto('/dashboard'); // Wait for initial connection await expect(page.locator('.connection-status')).toHaveText('Connected'); // Simulate server disconnect await fetch('/api/test/disconnect-all-sse'); // Verify reconnecting state await expect(page.locator('.connection-status')).toHaveText('Reconnecting...'); // Verify successful reconnection (EventSource auto-reconnects) await expect(page.locator('.connection-status')).toHaveText('Connected', { timeout: 10000, // Allow time for retry }); }); test('maintains connection in background tab', async ({ browser }) => { const context = await browser.newContext(); const page1 = await context.newPage(); const page2 = await context.newPage(); // Connect on page1 await page1.goto('/dashboard'); await expect(page1.locator('.connection-status')).toHaveText('Connected'); // Switch focus to page2 await page2.goto('about:blank'); await page2.bringToFront(); // Wait some time (simulating background state) await page2.waitForTimeout(5000); // Trigger event to backgrounded page await fetch('/api/test/trigger-event'); // Switch back and verify message was received await page1.bringToFront(); await expect(page1.locator('.message-count')).not.toHaveText('0'); });}); test.describe('SSE Fallback', () => { test('uses polyfill when EventSource unavailable', async ({ page }) => { // Disable native EventSource await page.addInitScript(() => { delete window.EventSource; }); await page.goto('/dashboard'); // Should still connect via polyfill await expect(page.locator('.connection-status')).toHaveText('Connected'); await expect(page.locator('.transport-type')).toHaveText('polyfill'); }); test('falls back to polling when all streaming fails', async ({ page }) => { await page.addInitScript(() => { delete window.EventSource; // Polyfill would be loaded, but let's simulate it failing too }); // Make SSE endpoint return error await page.route('**/api/events', route => route.abort()); await page.goto('/dashboard'); // Should have fallen back to polling await expect(page.locator('.transport-type')).toHaveText('polling'); });});While Playwright and similar tools can simulate many conditions, real device testing on physical phones and tablets remains essential. Mobile browsers exhibit behaviors that are difficult to simulate accurately, including background process handling, network radio management, and OS-level optimizations.
When SSE isn't available or fails, your application should degrade gracefully rather than breaking entirely. A well-designed fallback strategy ensures users always have access to updates, even if the experience is less optimal.\n\nThe Fallback Cascade
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
// transport/factory.tsinterface Transport { type: string; connect(): Promise<void>; disconnect(): void; onMessage(handler: (data: any) => void): void; onError(handler: (error: Error) => void): void; onStatusChange(handler: (status: ConnectionStatus) => void): void;} type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected'; interface TransportConfig { url: string; sseUrl?: string; wsUrl?: string; pollUrl?: string; authToken?: string;} class TransportFactory { private transports: Map<string, () => Transport> = new Map(); register(name: string, factory: () => Transport) { this.transports.set(name, factory); } async createBestTransport(config: TransportConfig): Promise<Transport> { const attempts = [ { name: 'sse', check: () => 'EventSource' in window }, { name: 'sse-polyfill', check: () => true }, // Polyfill always available { name: 'websocket', check: () => 'WebSocket' in window }, { name: 'long-polling', check: () => 'fetch' in window }, { name: 'short-polling', check: () => true }, ]; for (const { name, check } of attempts) { if (!check()) continue; const factory = this.transports.get(name); if (!factory) continue; try { const transport = factory(); await this.testConnection(transport); console.log(`Using transport: ${name}`); return transport; } catch (error) { console.warn(`Transport ${name} failed:, error`); continue; } } throw new Error('No working transport available'); } private testConnection(transport: Transport): Promise<void> { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { transport.disconnect(); reject(new Error('Connection timeout')); }, 5000); transport.onStatusChange((status) => { if (status === 'connected') { clearTimeout(timeout); resolve(); } }); transport.onError((error) => { clearTimeout(timeout); reject(error); }); transport.connect(); }); }} // Usageconst factory = new TransportFactory(); factory.register('sse', () => new SSETransport(config));factory.register('sse-polyfill', () => new SSEPolyfillTransport(config));factory.register('websocket', () => new WebSocketTransport(config));factory.register('long-polling', () => new LongPollingTransport(config));factory.register('short-polling', () => new ShortPollingTransport(config)); const transport = await factory.createBestTransport(appConfig);When falling back to polling, consider informing users. A subtle 'Updates may be delayed' indicator manages expectations while maintaining transparency. This is especially important for applications where real-time freshness is a key value proposition.
We've comprehensively covered browser support for Server-Sent Events. Let's consolidate the key insights:
What's Next:\n\nNow that we understand browser support and fallback strategies, we'll explore how to handle reconnection robustly—ensuring your SSE implementations recover gracefully from network interruptions and server restarts.
You now have a comprehensive understanding of SSE browser support. You can implement feature detection, deploy polyfills strategically, handle mobile-specific challenges, test systematically, and build graceful degradation into your applications. Next, we'll dive into reconnection handling.