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.
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.
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.
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.
Related #
- Browser Compatibility & Polyfills — the wider set of environments a real-time client must survive.
- Configuring nginx for WebSocket Upgrades — making sure your own proxy is not the one at fault.
- Terminating WSS with nginx and Let’s Encrypt — serving on 443 with a certificate corporate CAs will accept.
- Connection Lifecycle & Heartbeats — the heartbeat interval that keeps appliance timeouts from firing.
- WebSocket vs SSE vs WebRTC — choosing the fallback transport when the primary one is blocked.