WebSockets Behind Corporate Proxies and Firewalls #

Your app works everywhere except at one customer, where roughly 4% of sessions never connect and another 10% drop every ninety seconds. The customer’s IT team says nothing is blocked. Both statements are true: their proxy is not blocking WebSockets, it is inspecting them, and inspection means terminating and re-originating the connection with different rules than the browser expects.

Corporate networks are the single largest source of “works for me” real-time bugs, and the failure modes are specific enough to detect and, mostly, to work around.

Root cause #

A corporate network sits between your user and your server in a way the public internet does not. Five behaviours account for nearly every failure:

TLS inspection. The proxy holds a private CA installed on every managed device, terminates the TLS connection, inspects the plaintext, and re-originates it. Your wss:// becomes two connections, and the middle box decides whether to forward an upgrade at all. Many older inspection appliances simply do not, answering 200 or 403 instead of relaying the 101.

HTTP CONNECT for explicit proxies. When the browser is configured with an explicit proxy, a wss:// connection is established by issuing CONNECT host:443. A proxy that restricts CONNECT to a port allow-list will refuse anything outside 443 — which is why a WebSocket on port 8080 fails on exactly the networks where the equivalent HTTPS request succeeds.

Aggressive idle timeouts. Many appliances drop connections after 60 to 120 seconds of silence, regardless of what your server thinks. The socket dies with no close frame, so the client sees 1006 and reconnects, and the cycle repeats 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 the negotiation the client believed happened did not.

Buffering. An appliance that buffers to scan content will hold small frames until a buffer fills or a timer fires, adding seconds of latency to a protocol chosen for milliseconds.

Symptom to network cause A table matching corporate-network symptoms to the proxy or firewall behaviour that causes them and the practical workaround for each. Symptom to network cause What the user sees Likely cause Workaround Never connects instant failure inspection drops upgrade allow-list the host Fails on port 8080 works on 443 CONNECT port allow-list serve on 443 Drops every ~90s reconnect loop appliance idle timeout heartbeat < 45s Connects, wrong codec decode errors subprotocol header stripped verify socket.protocol Seconds of lag batched arrivals content scanning buffer larger, fewer messages Works in browser, not app desktop client fails no proxy auto-config honour system proxy Only the first row genuinely needs the customer's IT team — the rest are things you can fix on your side
Five of six rows have an application-side workaround. Reach for the IT conversation last, not first.

Resolution #

Design so that the common cases never arise, then detect and report the ones that do.

Serve on 443 and nothing else. This single decision eliminates the CONNECT port restriction, the “non-standard port” firewall rule, and most inspection policies that treat unusual ports as suspicious. Route by path — wss://api.example.com/ws — rather than by port, which is what the nginx configuration in terminating WSS with nginx and Let’s Encrypt already does.

Heartbeat faster than the shortest timeout you expect to meet. Appliance idle timeouts cluster around 60 and 120 seconds, so a 30-second application heartbeat keeps the connection non-idle from the middle box’s point of view. Application-level ping frames are what matter here — TCP keepalive is usually invisible to an inspecting proxy, because it re-originates the connection and applies its own timer.

Then detect the failure and tell the user something true:

const CONNECT_TIMEOUT_MS = 8_000;
const FAST_FAILURE_MS = 1_500;

export type Diagnosis = 'ok' | 'blocked' | 'unstable' | 'slow';

export async function diagnose(url: string): Promise<Diagnosis> {
const started = Date.now();
const socket = new WebSocket(url);

const outcome = await new Promise<Diagnosis>((resolve) => {
const timer = setTimeout(() => { socket.close(); resolve('slow'); }, CONNECT_TIMEOUT_MS);

socket.onopen = () => { clearTimeout(timer); resolve('ok'); };
socket.onclose = (ev) => {
clearTimeout(timer);
// A failure within ~1.5s that never opened means something in the path
// refused the upgrade outright — an appliance, not a slow network.
const fast = Date.now() - started < FAST_FAILURE_MS;
resolve(fast && ev.code === 1006 ? 'blocked' : 'unstable');
};
});

// An HTTPS request to the SAME host distinguishes "the host is unreachable"
// from "the host is reachable but WebSockets are not allowed through".
if (outcome === 'blocked') {
try {
await fetch(url.replace(/^wss:/, 'https:').replace(/\/ws$/, '/healthz'), { mode: 'cors' });
return 'blocked'; // HTTPS fine, WebSocket refused: it is the proxy
} catch {
return 'unstable'; // host unreachable: an ordinary network problem
}
}
return outcome;
}

That contrast — HTTPS to the same host succeeds, the WebSocket does not — is the evidence to put in front of a customer’s IT team, and it converts an unwinnable “your app is broken” conversation into a specific request: allow WebSocket upgrades to this hostname.

Finally, degrade rather than fail. If the socket cannot be established, fall back to polling the same API over HTTPS. It is worse in every way except the one that matters — it works — and the WebSocket vs SSE vs WebRTC comparison covers where each transport’s floor sits. Server-Sent Events are sometimes suggested as the fallback, but they are frequently broken by the same buffering appliances, so plain polling is the more reliable floor.

What TLS inspection does to your handshake An inspecting proxy terminates the TLS connection with a corporate certificate, applies its own policy to the upgrade request, and re-originates it to the server; if the policy does not relay upgrades the browser receives an ordinary response instead of the 101. What TLS inspection does to your handshake Browser Inspection proxy Your server CONNECT api.example.com:443 terminate TLS with the corporate CA GET /ws, Upgrade: websocket policy: relay the upgrade, or not? re-originated request (headers may differ) 101 Switching Protocols 101 relayed — or 200 if policy refuses Your server sees a perfectly normal handshake from the proxy — the failure is invisible in your logs
The connection you observe and the connection the user has are two different connections. That is why your logs look clean.

Verification #

Reproduce the environment rather than guessing at it.

# Does an explicit proxy allow CONNECT to your host and port?
curl -v --proxy http://corp-proxy:8080 --proxy-insecure \
-i -N --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://api.example.com/ws

# Is TLS being inspected? The issuer will be the corporate CA, not a public one.
echo | openssl s_client -connect api.example.com:443 2>/dev/null \
| openssl x509 -noout -issuer

# How long does an idle connection survive on this network?
npx wscat -c wss://api.example.com/ws --no-color # then wait, and time the drop

The issuer check takes five seconds and answers the biggest question immediately: if the certificate was issued by something like “Contoso Internal CA” rather than a public authority, every connection on that network is being terminated and re-originated, and your problem is policy rather than code.

Instrument the same signals in production. Report the client-side diagnosis with your telemetry, tagged by the customer or network, and you will find that these failures cluster on a handful of networks rather than being spread evenly — which is what turns a vague “some users have issues” into a specific, addressable list.

The heartbeat interval is the one setting that resolves the largest share of these reports, and choosing it is arithmetic rather than judgement: 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, and the user sees a drop your server never explains.

Operational checklist #

FAQ #

Can I tunnel WebSockets over something a proxy will not touch? #

Not reliably, and it is the wrong goal. Anything that successfully disguises a persistent bidirectional channel from a security appliance is, from the customer’s security team’s perspective, malware behaviour — and appliances get updated. The durable answer is to look like ordinary HTTPS traffic on 443 to a hostname the customer has allow-listed, which is exactly what a well-configured wss:// endpoint already is.

Does using port 443 really help if the proxy inspects everything? #

It helps with the large set of appliances that filter on port and protocol heuristics rather than deep inspection, which is most of them. Against genuine inspection it does not, and no configuration will: the appliance is deciding whether to relay an upgrade, and only a policy change resolves that. Port 443 removes the easy failures so the remaining ones are all the same, single, addressable cause.

Why does it work in Chrome but not in our Electron app? #

Because Electron and Node do not automatically honour system proxy configuration the way a browser does. A managed desktop typically has a PAC file or system proxy settings that Chrome reads and your app ignores, so the app tries a direct connection that the firewall drops. Read the system proxy explicitly and route through it — in Electron, session.setProxy with mode: 'system' is the starting point.

How do I tell IT what to allow? #

Be specific and small: the hostname, port 443, wss:// upgrades permitted, and no content inspection required on that path. Vague requests (“allow WebSockets”) get refused; a one-line rule for one hostname usually does not. Include the evidence from the HTTPS-succeeds-WebSocket-fails probe, which pre-empts the first question they will ask.

Is Server-Sent Events a safer fallback? #

Often not. SSE is plain HTTP and therefore passes more firewalls, but it is a streaming response, and the same content-scanning appliances that buffer WebSocket frames buffer SSE events — sometimes until the response completes, which for an infinite stream is never. Plain HTTPS polling is uglier and far more reliable as a floor, which is the trade the comparison in when to use WebSockets over Server-Sent Events sets out.

Back to Browser Compatibility & Polyfills