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.
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 |
100–500 |
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 inCONNECTINGindefinitely. Only the probe timeout catches this — never rely ononerroralone to trigger fallback. EventSourceis one-directional. SSE can only push server→client. On the SSE tier you must route client→server messages over a separatefetch/POSTchannel, or yoursend()silently no-ops.- Mixed-content blocking. A page served over
https://cannot open aws://socket; the constructor throwsSecurityErrorsynchronously. Always probewss://so feature detection does not crash on the secure-origin rule. - Stale handlers after close. If you call
close()without nullingonopen/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 #
- Configuring Nginx for WebSocket upgrades walks through the
Upgrade/Connectionheader map, timeout tuning, and upstream keepalive that keep the native tier from collapsing into a fallback.
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.
Related #
- WebSocket vs SSE vs WebRTC comparison — choose the primary transport before designing the fallback chain.
- Protocol Handshake Mechanics — the upgrade exchange that proxies interfere with.
- Configuring Nginx for WebSocket upgrades — proxy config that keeps the native tier alive.
- Security & TLS Configuration — why probing
wss://avoids mixed-content failures.