WebSocket closed with code 1006

The connection died with no Close frame. Four causes, and how to tell them apart.

What it means

1006 never travels over the network. RFC 6455 § 7.4.1 forbids it in a Close frame, so your own WebSocket client fills it in when the connection died and no Close frame ever arrived. So it tells you one fact: nobody said goodbye. It does not tell you why.

How to fix it

Here is everything the event gives you: event.code is 1006, event.reason is an empty string, event.wasClean is false. That is deliberate. The spec bans the browser from telling a script whether DNS failed, the port was shut, TLS broke or the handshake was refused, because otherwise any web page could scan your network.

Do not mix it up with 1005. There a Close frame did arrive, just without the two-byte status code, and wasClean is true once the closing handshake finished. Different problem, different place to look.

There are four causes in practice. To your JavaScript all four look the same. Chrome's console says a little more: a failed handshake prints Unexpected response code: 403.

1. The handshake failed. The server answered the Upgrade with something other than 101: a 404, a 403, a 502, a redirect. Browsers never follow a redirect here (the spec sets the request's redirect mode to error), so a 301 is just a failure. Check this first. It is plain HTTP and you can read all of it:

curl -i -N --http1.1 --max-time 5 \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  https://example.com/ws

--http1.1 is not optional. Without it curl negotiates HTTP/2 over TLS, the Upgrade headers are dropped, and you get a plain 200, which is a fake answer. That Sec-WebSocket-Key is the sample value from the RFC: base64 of 16 bytes. Fine for a manual check, but RFC 6455 § 4.1 requires a real client to generate a fresh random nonce every time.

HTTP/1.1 101 Switching Protocols means the handshake works and the cause is elsewhere. Any other status is your answer.

2. An idle timeout on a proxy. The default is 60 seconds nearly everywhere. nginx ships proxy_read_timeout 60s. An AWS ALB ships idle_timeout at 60 seconds. The tell is the clock: the 1006 lands on the same second every time.

Raise it on the proxy:

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

Then add a heartbeat in the app, because the next proxy in the chain has a timeout of its own that you do not know about:

const ping = setInterval(() => {
  if (socket.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}');
}, 30_000);
socket.addEventListener('close', () => clearInterval(ping));

The server has to answer it. nginx's proxy_read_timeout only counts bytes coming from the server, so client pings alone fix nothing there. An ALB differs here: traffic in either direction resets it. And JavaScript cannot send the protocol's own Ping frame (RFC 6455 § 5.5.2), so the heartbeat must be an ordinary message both sides agree on.

3. The server process died. A restart, the OOM killer, a deploy. The time is random and every connection drops at once. That is what tells it apart from a timeout.

4. The network changed. Wi-Fi to cellular, a VPN, a laptop going to sleep. On phones this is the usual cause. There is nothing to fix, so just reconnect, with a backoff:

let attempt = 0;
function connect() {
  const socket = new WebSocket(url);
  socket.addEventListener('open', () => { attempt = 0; });
  socket.addEventListener('close', (e) => {
    if (e.code === 1000) return;              // normal close, do not retry
    const delay = Math.min(30_000, 500 * 2 ** attempt++);
    setTimeout(connect, delay * (0.5 + Math.random()));
  });
}

The jitter is not optional. Without it a thousand clients dropped by one restart come back in the same millisecond and take the server down again.

See it on your own traffic

Causes 1 and 2 live in the HTTP exchange, and that is fully visible from the client side. Find the Upgrade request to /ws in the request list and read the status: a 101 means the handshake passed, any other status is your cause. If it is a 101, compare how long each connection lived: the same ceiling every time means a proxy timeout. See Capture & Inspect Traffic for how Solpuga records a stream.

View in Solpuga

Tool for this page

WebSocket close code lookupEnter a WebSocket close code to get its meaning, the spec that defines it, and what usually causes it.

Related