← Debugging Guides

阅读中文版本

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.

intermediatedebuggingwebsocketnginxproxy

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 -8

Interpretation

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.yaml

Interpretation

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_timeout

Interpretation

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.