WebSocket Browser Compatibility & Polyfills #

Native WebSocket ships in every browser shipped since 2012, so the compatibility problem is no longer the API — it is the network path. A user on a corporate VPN loads your dashboard, the WebSocket constructor succeeds, readyState advances to CONNECTING, and then nothing happens. No open, no error for 30 seconds, just a hung socket because an intercepting proxy stripped the Upgrade header and is buffering the response. The browser supports WebSockets perfectly; the path between it and your server does not. Robust real-time apps treat transport as something to detect and negotiate at runtime, not something to assume. This page builds a feature-detection probe, a fallback transport chain (WebSocket → SSE → long-poll), and a single adapter interface so the rest of your code never branches on transport type.

Prerequisites #

This guide assumes you have already chosen WebSockets as your primary transport — if you are still weighing options, start with the WebSocket vs SSE vs WebRTC comparison, since the fallback chain here reuses SSE as a degraded mode. You should understand the protocol handshake mechanics that proxies interfere with, and have a reverse proxy that forwards upgrade headers correctly — see Configuring Nginx for WebSocket upgrades. This whole topic sits under Real-Time Protocol Selection & Architecture.

How fallback negotiation flows #

The probe runs once at startup. It races a real upgrade against a timeout; if the socket opens it commits to native WebSocket, otherwise it walks down a tier list until one transport completes a handshake. Every tier exposes the same send/onmessage/close surface, so application code is written once.

Transport fallback negotiation A startup probe tries native WebSocket first, then degrades to Server-Sent Events and finally HTTP long-polling, each feeding one adapter interface. Startup probe 3s timeout race 1. WebSocket full-duplex, native 2. SSE server to client 3. Long-poll last resort blocked blocked Adapter interface send / onmessage / close

Core implementation #

Two pieces matter: a probe that distinguishes “blocked” from “slow”, and an adapter that hides the chosen transport. Start with the probe. The key insight is that a feature check (typeof WebSocket) only tells you the API exists — it says nothing about whether the upgrade will survive the network. So we attempt a real connection against a tiny probe endpoint and bound it with a timeout.

// Ordered list of transports we will try, best first.
type Transport = "websocket" | "sse" | "longpoll";
const TRANSPORT_TIERS: Transport[] = ["websocket", "sse", "longpoll"];

const PROBE_TIMEOUT_MS = 3_000; // silent-drop proxies never error; the timeout is our signal
const PROBE_URL = "wss://probe.example.com/ping";

// Resolve the best transport that actually completes a handshake from THIS network.
async function negotiateTransport(): Promise<Transport> {
// Feature detection: if the constructor is missing, skip straight to fallbacks.
const wsUsable = typeof WebSocket !== "undefined" && (await probeWebSocket());
if (wsUsable) return "websocket";

// EventSource powers our SSE tier; long-poll has no API gate, it always "exists".
if (typeof EventSource !== "undefined") return "sse";
return "longpoll";
}

function probeWebSocket(): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const ws = new WebSocket(PROBE_URL);

// A blocked upgrade hangs in CONNECTING forever; the timer is the verdict.
const timer = setTimeout(() => finish(false), PROBE_TIMEOUT_MS);

function finish(ok: boolean) {
if (settled) return; // guard: open + timeout can both fire on flaky links
settled = true;
clearTimeout(timer);
try { ws.close(); } catch { /* already closing */ }
resolve(ok);
}

ws.onopen = () => finish(true); // upgrade survived the path → native works
ws.onerror = () => finish(false); // explicit rejection → fall back immediately
});
}

Now the adapter. Each transport is wrapped so the rest of the app calls send() and subscribes to messages without knowing whether bytes leave over a duplex socket, an EventSource, or a POST loop. Outbound messages queue while the connection is mid-handshake and flush on open — this is what prevents the “first message dropped” bug.

interface RealtimeConnection {
send(data: unknown): void;
onMessage(handler: (data: unknown) => void): void;
close(): void;
}

const MAX_QUEUE = 500; // backpressure cap; beyond this we shed load instead of OOMing

function createConnection(transport: Transport, url: string): RealtimeConnection {
const queue: unknown[] = [];
let open = false;
let handler: (data: unknown) => void = () => {};

// Native WebSocket and SSE share enough surface to share one wrapper here;
// long-poll would supply its own send()/receive loop behind the same interface.
const sock =
transport === "websocket" ? new WebSocket(url)
: transport === "sse" ? new EventSource(url) as unknown as WebSocket
: new WebSocket(url); // long-poll adapter omitted for brevity

sock.onopen = () => {
open = true;
while (queue.length) sock.send(JSON.stringify(queue.shift())); // flush in order
};
sock.onmessage = (e: MessageEvent) => handler(JSON.parse(e.data));

return {
send(data) {
if (open) return sock.send(JSON.stringify(data));
if (queue.length >= MAX_QUEUE) throw new Error("send queue overflow"); // fail loud
queue.push(data); // buffer until handshake completes
},
onMessage(fn) { handler = fn; },
close() {
sock.onopen = sock.onmessage = sock.onerror = null; // detach before close (leak guard)
sock.close();
},
};
}

Configuration reference #

Parameter Type Default Production value Notes
PROBE_TIMEOUT_MS number 3000 3000 Minimum that reliably distinguishes a slow link from a silent-drop proxy.
TRANSPORT_TIERS Transport[] ["websocket","sse","longpoll"] same Drop longpoll if you have no bidirectional fallback need.
MAX_QUEUE number 500 100500 Per-connection outbound buffer before shedding load.
PROBE_URL string dedicated wss:// endpoint Must answer fast; do not reuse the main data endpoint.
withCredentials (SSE) boolean false true for cross-origin auth Required to send cookies on the EventSource tier.
reconnect base delay number 1000 ms 1000 ms Feeds exponential backoff after a transport drops.

Edge cases & gotchas #

  • Silent-drop proxies never fire onerror. An intercepting proxy that buffers the response leaves the socket in CONNECTING indefinitely. Only the probe timeout catches this — never rely on onerror alone to trigger fallback.
  • EventSource is one-directional. SSE can only push server→client. On the SSE tier you must route client→server messages over a separate fetch/POST channel, or your send() silently no-ops.
  • Mixed-content blocking. A page served over https:// cannot open a ws:// socket; the constructor throws SecurityError synchronously. Always probe wss:// so feature detection does not crash on the secure-origin rule.
  • Stale handlers after close. If you call close() without nulling onopen/onmessage, a late event from the OS socket buffer can fire on a connection you thought was dead. The adapter detaches handlers first for exactly this reason.

Verification #

Confirm the negotiated transport in the browser before blaming the server. In DevTools, the Network → WS tab lists open WebSocket frames; if it is empty but data is flowing, you fell back to SSE or long-poll. Check the chosen tier from the console:

const t = await negotiateTransport();
console.log("transport:", t); // expect "websocket" on a clean network

On the server host, verify the upgrade actually reached the backend rather than dying at the proxy:

# Are there established sockets on the app port (not just the proxy)?
ss -tnp state established '( dport = :8080 or sport = :8080 )' | head

# Did nginx log 101 (good) or 400/502 (header stripped)?
grep ' 101 ' /var/log/nginx/access.log | tail

A 101 Switching Protocols line confirms the upgrade survived; a 400 or 502 means the proxy mangled it and your clients are silently degrading to fallback transports.

Guides in this area #

FAQ #

Do I still need polyfills for modern browsers? #

No JavaScript polyfill is needed for the WebSocket API — it has been universal since 2012. What you need is a transport fallback, because the failures are network-layer (proxies, firewalls), not API-layer. Detect and degrade at runtime rather than shipping a constructor shim.

How is feature detection different from connection probing? #

typeof WebSocket !== "undefined" only proves the API exists in the runtime. It cannot tell you whether the upgrade will survive an intercepting proxy. Probing opens a real connection against a timeout, which is the only reliable signal on networks that drop the upgrade silently.

Why does my socket hang in CONNECTING with no error? #

A proxy is buffering or stripping the upgrade response, so the browser never receives the 101 and never fires onerror. This is exactly the case the probe timeout exists to catch — treat a timed-out probe as “blocked” and move to the next tier.

Can I use SSE as a full replacement for WebSockets? #

Only for server→client streams. EventSource has no send path, so any client→server traffic on the SSE tier must go over a separate fetch request. For genuinely bidirectional needs where WebSockets are blocked, long-poll is the true fallback.

Back to Real-Time Protocol Selection & Architecture