WebSocket Handshake Fails Behind a Proxy — Upgrade Not Forwarded
Fix WebSocket 400/failed upgrades where nginx or gateways do not forward the Upgrade handshake, configure hop-by-hop headers, and keep the connection alive.
Symptoms
- The browser fails the handshake with 400 Bad Request or "WebSocket connection to ... failed" during connect
- Direct-to-app WebSocket connections succeed; through the proxy or ingress they fail before any data flows
- Connections established through the proxy drop after exactly the proxy idle timeout
Possible causes
- The proxy does not forward the Upgrade and Connection headers, so the origin never sees a protocol switch request
- Missing proxy_http_version 1.1, leaving the upstream on HTTP/1.0 where Upgrade cannot work
- Read and send timeouts on the proxy close idle WebSocket connections at the configured boundary
- A WAF or middleware strips hop-by-hop headers or rejects the 101 response
Diagnosis steps
Compare direct and proxied handshakes
curl -s -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" https://app.example.com/ws | head -8Interpretation
A 101 with Sec-WebSocket-Accept means the path is fine; a 400 or a plain 200 means the upgrade request was not understood or forwarded.
Inspect proxy upgrade configuration
grep -Rn "proxy_set_header Upgrade\|proxy_http_version" nginx.conf ingress.yamlInterpretation
Without Upgrade and Connection header forwarding plus HTTP/1.1, the proxy terminates the handshake by definition.
Correlate drops with timeouts
grep "ws" access.log | awk '{print $NF}' | sort | uniq -c; check connection durations vs proxy_read_timeoutInterpretation
Closures at exactly the idle timeout with healthy traffic patterns identify keepalive gaps, not failures.
Root cause
The WebSocket handshake is an HTTP/1.1 Upgrade exchange using hop-by-hop headers (Connection, Upgrade) that proxies strip by default. A proxy that neither forwards them nor speaks HTTP/1.1 to the upstream never delivers the protocol switch, and the handshake fails with a 400 before any WebSocket framing begins.
Fix
Configure the proxy path for upgrades: HTTP/1.1 to the upstream, forward Upgrade and Connection headers, disable buffering, and set read timeouts beyond the application heartbeat interval so idle-but-healthy connections survive.
Risk note
Very long read timeouts hold proxy resources for dead connections. Pair them with application heartbeats and shorter client-side reconnect logic.
Verify the fix
The handshake returns 101 with a valid Sec-WebSocket-Accept through the full path, a long-idle connection survives past the previous drop boundary, and reconnect tests recover cleanly.
Symptom walkthrough
The handshake response decides the diagnosis in one request:
$ curl -s -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://app.example.com/ws
HTTP/1.1 400 Bad Request <- proxy refused the upgrade
versus the healthy direct path returning 101 Switching Protocols with a Sec-WebSocket-Accept header. The two-hop divergence — direct works, proxied fails before any data — indicts the proxy upgrade configuration. The second act shows after you fix the handshake: connections dropping at exactly proxy_read_timeout because no frames flow during idle periods.
Fix in practice
The canonical nginx upgrade block:
location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1; # Upgrade requires 1.1
proxy_set_header Upgrade $http_upgrade; # forward hop-by-hop
proxy_set_header Connection "upgrade"; # explicitly, not $connection
proxy_set_header Host $host;
proxy_read_timeout 3600s; # survive idle windows
proxy_buffering off;
}
Then keep the connection demonstrably alive from the application — a ping frame every 30 seconds keeps read timers fed and lets intermediate state tables (NAT, LB) retain the flow. And verify end to end after the config: handshake 101, frames both ways, idle survival past the old drop boundary.