← Debugging Guides

阅读中文版本

WebSocket 1006 abnormal closure — Proxy Upgrade and Idle Timeouts

Diagnose WebSocket 1006 errors by checking the handshake response, proxy Upgrade headers, idle timeouts, and server crashes, then add heartbeats and clean reconnects.

intermediatedebuggingwebsocketnginx

Symptoms

  • WebSocket closes with code 1006 and no close reason
  • The browser console shows Error during WebSocket handshake: Unexpected response code: 502
  • Connections drop after roughly 60 seconds of inactivity
  • Works directly against the app but fails through nginx or a load balancer

Possible causes

  • The reverse proxy does not forward Connection and Upgrade headers
  • A proxy read timeout closes idle connections before the next message
  • The application process crashes or is restarted during the connection
  • TLS termination or origin checks reject the upgrade request

Diagnosis steps

Perform the upgrade by hand

curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" https://example.com/ws

Interpretation

A 101 Switching Protocols response confirms the proxy and app accept the upgrade. A 400, 403, or 502 identifies which layer rejected it before the WebSocket frame exchange.

Test with a WebSocket client

websocat -v wss://example.com/ws

Interpretation

websocat prints the handshake and close frames. A clean close code differs from 1006; 1006 means the TCP connection ended without a close frame.

Check proxy logs at the drop time

tail -n 100 /var/log/nginx/error.log | grep -E 'upstream|timed out|closed'

Interpretation

An upstream timed out entry at the same interval as the disconnect points to proxy_read_timeout. Premature close points to the app or a crash.

Measure the disconnect interval

kubectl logs deploy/ws-server --since=30m | grep -iE 'close|ping|pong|error' | tail -40

Interpretation

Disconnects at a fixed interval indicate an idle timeout. Random intervals suggest network instability, restarts, or memory pressure killing the process.

Root cause

Code 1006 is generated locally when the connection ends without a WebSocket close frame. That happens when an intermediary drops an idle TCP connection, the server crashes, or the handshake never completes because Upgrade headers were stripped. The browser cannot distinguish these causes, so the proxy and server logs are the only reliable evidence.

Fix

Configure the reverse proxy to use HTTP/1.1, forward Upgrade and Connection, and set proxy_read_timeout longer than the heartbeat interval. Send ping frames periodically and reconnect with exponential backoff when the socket closes. Fix application crashes and memory limits, ensure TLS and origin checks accept the intended host, and use a load balancer that supports WebSocket upgrades.

Risk note

Very long proxy timeouts leave dead connections open and consume resources. Reconnecting in a tight loop amplifies load during an outage. Disabling origin checks to make a connection work exposes the endpoint to cross-site WebSocket hijacking.

Verify the fix

Confirm the handshake returns 101 and keep the connection idle longer than the former timeout; it should remain open with heartbeats. Then kill one backend and verify the client reconnects cleanly with a new connection and no 1006 in the console.

1006 is a local diagnosis, not a server message

The peer never sends code 1006. The browser reports it when the connection dies without a close frame, which can be a proxy timeout, a crash, or a failed handshake.

WebSocket connection to 'wss://example.com/ws' failed:
Error during WebSocket handshake: Unexpected response code: 502

Test the handshake without a browser

curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
  https://example.com/ws

A correct response starts with HTTP/1.1 101 Switching Protocols. Anything else identifies the rejecting layer before frames are exchanged.

Fix the proxy and keep the socket alive

location /ws {
    proxy_pass http://ws_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;
}

Send ping frames every 20 to 30 seconds and treat a missing pong as a dead connection. On the client, reconnect with exponential backoff and jitter, and resume from a message ID rather than replaying everything.

Verify

Confirm the handshake is 101 and leave the connection idle longer than the old timeout; heartbeats should keep it alive. Tail nginx and application logs during the test to ensure no upstream timeout or crash appears. Then restart one backend and verify the client reconnects once, without a 1006 storm or duplicate message processing.