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 #

The networks your users are actually on #

Browser support for WebSockets has been universal for over a decade, so compatibility work has moved somewhere else entirely: the network between the browser and your server. A protocol that works on every browser and fails at one customer is not a browser problem, and treating it as one wastes weeks.

Five middle-box behaviours account for essentially all of it.

TLS inspection. A corporate proxy holds a private certificate authority installed on every managed device, terminates the connection, inspects the plaintext and re-originates it. Your wss:// becomes two connections with an appliance in the middle deciding whether to relay an upgrade at all — and many older ones do not, answering 200 or 403 instead of passing the 101 through.

CONNECT restrictions. With an explicit proxy configured, a wss:// connection is established by issuing CONNECT host:443. Proxies commonly restrict CONNECT to a port allow-list, which is why a WebSocket on port 8080 fails precisely on the networks where the equivalent HTTPS request succeeds.

Idle timeouts. Appliances drop connections after 60 to 120 seconds of silence regardless of what your server thinks. There is no close frame, so the client observes 1006 and reconnects, forever.

Header stripping. Some proxies remove headers they do not recognise, including Sec-WebSocket-Protocol and Sec-WebSocket-Extensions. The connection succeeds and then behaves subtly wrongly, because a negotiation the client believed happened did not.

Content-scanning buffers. An appliance that buffers to inspect payloads holds small frames until a buffer fills or a timer fires, adding seconds of latency to a protocol chosen for milliseconds.

Two design decisions remove most of these before they occur: serve on port 443 and route by path rather than by port, and keep the application heartbeat under 30 seconds so no appliance ever sees an idle connection. Application ping frames are what matter, because an inspecting proxy re-originates the connection and applies its own timer regardless of TCP keepalive.

For what remains, detect rather than guess. A WebSocket that fails within a second or two while an ordinary HTTPS request to the same host succeeds is conclusive evidence that the transport is blocked rather than the host unreachable — and that contrast is what converts an unwinnable support conversation into a specific, granting-sized request.

Network symptom to cause and workaround A table matching each restricted-network symptom to the middle-box behaviour causing it and the application-side workaround available. Network symptom to cause and workaround Cause Workaround Never connects inspection drops upgrade allow-list the host Fails off port 443 CONNECT allow-list serve on 443 Drops every 60-90s appliance idle timer heartbeat under 30s Wrong codec negotiated header stripped read socket.protocol Seconds of lag scanning buffer fewer, larger messages Works in browser, not app no proxy auto-config honour system proxy Five of six have a fix on your side of the connection — reach for the IT conversation last
Design for the hostile network and the open one takes care of itself.

The heartbeat interval is the single setting that resolves the largest share of these reports, and choosing it is arithmetic: it must be comfortably shorter than the shortest idle timeout anywhere in the path.

How long a dead socket survives, by heartbeat interval Detection latency for heartbeat intervals 20, 30, 45, 60, 90 seconds with a 10 second pong timeout: worst case is always interval plus timeout. How long a dead socket survives, by heartbeat interval pong timeout 10s — blue = best case, red = worst case (interval + timeout) 0s 25s 50s 75s 100s 20s 30s 45s 60s 90s
Detection latency by interval. Anything at or above 60 seconds leaves a window in which a 60-second appliance timeout fires first, producing a drop your server never explains.

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.

Do I still need a WebSocket polyfill? #

No. Every browser in meaningful use has supported WebSockets natively for over a decade, and the transport-switching libraries that once existed for this reason now solve a different problem — falling back when the network blocks the upgrade, not when the browser lacks the API. If you are carrying a polyfill for compatibility, it is dead weight; if you are carrying a fallback for hostile networks, that is a live concern with a different design.

What should the fallback transport be? #

Plain HTTPS polling, not Server-Sent Events. SSE is ordinary HTTP and passes more firewalls, but the same content-scanning appliances that buffer WebSocket frames buffer streaming responses — sometimes until the response completes, which for an infinite stream never happens. Polling is uglier, more expensive and dramatically more reliable as a floor.

How do I tell whether a specific customer’s network is the problem? #

Check the certificate issuer from inside their network: openssl s_client will show a corporate CA rather than a public one if TLS is being inspected, which takes five seconds and answers the biggest question immediately. Pair it with the HTTPS-succeeds-WebSocket-fails probe, and you have the two pieces of evidence any network team will ask for.

Should I detect a blocked transport before showing the app? #

Detect it in parallel with rendering, not before. Blocking the first paint on a probe that may take several seconds trades a working degraded experience for a blank screen, which is strictly worse. Render the interface immediately, attempt the connection, and if it fails fast while an HTTPS request to the same host succeeds, switch to the polling fallback and tell the user plainly that live updates are unavailable on this network.

What should a desktop or mobile client do differently? #

Honour the system proxy configuration, which browsers do automatically and native runtimes usually do not. A managed desktop typically has a PAC file or system proxy settings that Chrome reads and your Electron or native app ignores, so the app attempts a direct connection the firewall drops. That single difference accounts for most “works in the browser, fails in our app” reports on corporate networks.

Does WebSocket work in a Service Worker or Web Worker? #

In a dedicated worker, yes — the constructor is available and a worker is often a good home for a connection, because message parsing then happens off the main thread. In a Service Worker it is available but rarely useful: service workers are terminated aggressively when idle, so a long-lived connection there will be killed at unpredictable moments. Keep the socket in the page or a dedicated worker and use the service worker only for background sync of queued messages.

What about environments with no browser at all? #

Node has had a global WebSocket since version 22, and ws remains the standard library for servers and older runtimes. The relevant difference for compatibility work is that non-browser clients send no Origin header and honour no proxy configuration automatically — so both your origin checks and your connectivity assumptions need an explicit answer for them rather than inheriting browser behaviour.

Back to Real-Time Protocol Selection & Architecture