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.
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
- The stream dies after 60 seconds: proxy_read_timeoutA stream that dies on the same second is a timeout, not the network. Default values in nginx, ALB, Cloudflare, Heroku and Envoy, and why a heartbeat is safer.
- The seventh tab never loads: the six-connection limitA browser opens at most six HTTP/1.1 connections per origin. One EventSource holds one of them forever, so the seventh tab just waits.