Websocket close code 1006
Log the close event and read three fields together. A server that shuts the socket down properly gives code=1000 reason="set by server" wasClean=true. A transport that dies gives code=1006 reason="" wasClean=false. Code 1006 never travels on the wire, so your client invented it, and that fact is the whole diagnosis.
Why check this
Run this after a change to the server's shutdown path, to a proxy or load balancer in front of the socket, or to the client's reconnect code. Run it again the moment a bug report says the connection drops and nobody can name the layer that dropped it.
The failure it prevents is time spent on the wrong component. The client reports 1006, somebody reads that as a network fault, and two people start on the load balancer. The cause is a message handler that throws and tears the socket down without a close frame. The code points at the right half of the system, but only once you know that one of these codes cannot come from a server.
The check tells you which codes your client can observe and which side produced each one. It cannot separate a crashed process from a cut cable, because both reach the client as the same 1006.
Prerequisites
- Node 22 or later. The global
WebSocketis a client only, so the server half needs a library. npm i ws@8in an empty directory. See the ws documentation.- A free port. Check with
netstat -ano | grep 9481first and pick another number if anything answers. - RFC 6455 section 7.4.1 for the registered codes, and MDN on CloseEvent for the fields the client exposes.
- The target server,
wsprobe.mjs. Every path on it produces one lifecycle on demand.
// wsprobe.mjs - one target for every WebSocket lifecycle check. node wsprobe.mjs 9481
import { WebSocketServer } from 'ws';
const port = Number(process.argv[2] ?? 9481);
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const wss = new WebSocketServer({ port }, () => log('listening on', port));
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://127.0.0.1');
const arg = (k, d) => Number(url.searchParams.get(k) ?? d);
log('open', req.url);
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; log('pong from client'); });
ws.on('message', (d) => { log('recv', JSON.stringify(String(d))); if (url.pathname === '/echo') ws.send(String(d)); });
ws.on('close', (c, r) => log('server close event', c, JSON.stringify(String(r))));
if (url.pathname === '/close') {
setTimeout(() => { log('ws.close(', arg('code', 1000), ')'); ws.close(arg('code', 1000), url.searchParams.get('reason') ?? ''); }, arg('after', 300));
}
if (url.pathname === '/crash') {
setTimeout(() => { log('ws.terminate(), no close frame'); ws.terminate(); }, arg('after', 300));
}
if (url.pathname === '/ping') {
const t = setInterval(() => { log('ws.ping()'); ws.ping(); }, arg('every', 1000));
ws.on('close', () => clearInterval(t));
}
if (url.pathname === '/idle') {
const every = arg('every', 3000);
const t = setInterval(() => {
if (ws.isAlive === false) { log('no pong since last sweep, terminate'); clearInterval(t); return ws.terminate(); }
ws.isAlive = false; log('sweep: ws.ping()'); ws.ping();
}, every);
ws.on('close', () => clearInterval(t));
}
});
- The client,
wslog.mjs. It uses the sameWebSocketobject a browser gives you, so what it prints is what your front end can see.
// wslog.mjs - Node 22 global WebSocket, the same API a browser gives you.
// node wslog.mjs "ws://127.0.0.1:9481/close?code=1001"
const t0 = Date.now();
const log = (...a) => console.log(String(Date.now() - t0).padStart(6), 'ms', ...a);
const ws = new WebSocket(process.argv[2]);
ws.onopen = () => log('open, readyState', ws.readyState);
ws.onmessage = (e) => log('message', JSON.stringify(e.data));
ws.onerror = (e) => log('error event, type', e.type);
ws.onclose = (e) => {
log('close code=' + e.code, 'reason=' + JSON.stringify(e.reason), 'wasClean=' + e.wasClean);
process.exit(0);
};
- A wire reader,
rawws.mjs. It does the handshake by hand and prints the bytes, which is the only way to see a close code before a library interprets it.
// rawws.mjs - hand-rolled handshake, then hexdump every byte the server sends.
// node rawws.mjs 9481 "/close?code=1000&reason=done"
import net from 'node:net';
import crypto from 'node:crypto';
const [port, path] = [process.argv[2], process.argv[3]];
const t0 = Date.now();
const at = () => String(Date.now() - t0).padStart(6) + ' ms';
const key = crypto.randomBytes(16).toString('base64');
const s = net.connect(Number(port), '127.0.0.1', () => {
s.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
});
let head = false;
s.on('data', (b) => {
if (!head) { const i = b.indexOf('\r\n\r\n'); console.log(at(), b.subarray(0, b.indexOf('\r\n')).toString()); head = true; b = b.subarray(i + 4); if (!b.length) return; }
console.log(at(), 'frame', b.toString('hex').replace(/(..)/g, '$1 ').trim());
if (b[0] === 0x88) { console.log(at(), 'close frame, leaving'); process.exit(0); }
});
s.on('end', () => console.log(at(), 'FIN from server, no close frame'));
s.on('close', () => process.exit(0));
Steps
- Step 1.
Start the target in its own terminal and leave it running.
node wsprobe.mjs 948122:40:54.395 listening on 9481 - Step 2.
Read the code the client reports for three codes the server sends on purpose.
for c in 1000 1001 1011; do node wslog.mjs "ws://127.0.0.1:9481/close?code=$c&reason=set%20by%20server"; done58 ms open, readyState 1 358 ms close code=1000 reason="set by server" wasClean=true 85 ms open, readyState 1 385 ms close code=1001 reason="set by server" wasClean=true 92 ms open, readyState 1 392 ms close code=1011 reason="set by server" wasClean=trueAll three arrive with the reason intact and
wasClean=true, because each came from a close frame the server sent. - Step 3.
Kill the transport instead, with no close frame, and read the same three fields.
node wslog.mjs "ws://127.0.0.1:9481/crash"64 ms open, readyState 1 381 ms close code=1006 reason="" wasClean=false - Step 4.
Read the wire for the clean case, then look at what the server logged for the same connection.
node rawws.mjs 9481 "/close?code=1000&reason=done"8 ms HTTP/1.1 101 Switching Protocols 312 ms frame 88 06 03 e8 64 6f 6e 65 312 ms close frame, leaving88is FIN plus opcode 8, a close frame.06is the payload length.03 e8is 1000 as a big-endian 16-bit integer, and64 6f 6e 65isdone. The server terminal, for that same connection, printed this:22:51:31.702 open /close?code=1000&reason=done 22:51:32.005 ws.close( 1000 ) 22:51:32.011 server close event 1006 ""One connection, two codes. The client read 1000 from the frame. The server sent 1000, got no close frame back because
rawws.mjsexits instead of replying, and recorded 1006 for the same socket. - Step 5.
Read the wire for the terminated case.
node rawws.mjs 9481 "/crash"6 ms HTTP/1.1 101 Switching Protocols 318 ms FIN from server, no close frameNo frame of any kind. The TCP connection ends and the client fills in 1006 by itself.
- Step 6.
Ask the client API to send the codes you have been reading, and see which ones it refuses.
node -e "const w=new WebSocket('ws://127.0.0.1:9481/echo');w.onopen=()=>{for(const c of [1001,1005,1006,1011,4001]){try{w.close(c,'x');console.log(c,'sent')}catch(e){console.log(c,e.name+':',e.message)}}process.exit(0)}"1001 InvalidAccessError: invalid code 1005 InvalidAccessError: invalid code 1006 InvalidAccessError: invalid code 1011 InvalidAccessError: invalid code 4001 sentClient code may send 1000 or anything from 3000 to 4999, and nothing else. A front end cannot reproduce a 1001 or an 1011 for a test.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| code=1000 wasClean=true | Normal closure, close frame exchanged | Nothing. Both sides agreed to stop. |
| code=1001 wasClean=true | The server is going away: shutdown, deploy, or a page navigating | Expected during a rolling restart. Confirm How to test websocket reconnect covers it. |
| code=1006 wasClean=false with an empty reason | No close frame arrived. Your client synthesised the code | Look at the transport and at server crashes, not at application logic. |
| code=1011 wasClean=true | The server hit an unexpected condition and said so | Read the server log for that connection. The code is a report, not a cause. |
| code=1005 in a server log | The peer closed with no status code at all | Usually a client calling close() with no arguments. Pass a code. |
Common mistakes
What to check next
- How to test websocket reconnect: 1006 is the code a reconnect loop acts on, and the loop has its own defect.
- Websocket connection timeout: the most common source of an unexplained 1006 is a liveness sweep the client never sees.
- How to check websocket ping pong: control frames keep the socket from being closed as dead, and the browser cannot observe them.
- How to test websocket connection: confirm the socket opens at all before reading how it ends.
- How to check websocket connection in chrome: read the same close event in DevTools.
FAQ
What is the websocket close reason and when is it empty?
The reason is a UTF-8 string of up to 123 bytes carried in the close frame after the two code bytes. Step 4 shows done as 64 6f 6e 65. When the code is 1006 there was no frame, so the reason is always empty.
What do the standard websocket close codes mean?
1000 is a normal closure, 1001 the endpoint going away, 1002 a protocol error, 1009 a message too large, 1011 an unexpected server condition. Codes 1004, 1005 and 1006 are reserved and never sent. Your own codes go in 4000 to 4999.
What does websocket close code 1001 mean in practice?
The server is shutting down or the page is navigating away. During a rolling deploy every connected client gets 1001 within a few seconds, which is the burst a reconnect policy has to spread out.
Why does the server log 1005 when my client closed normally?
Calling close() with no arguments sends a close frame with an empty payload, so no status code is present. The peer reports 1005, the reserved code for that case. Pass close(1000, 'reason') instead.
Verified
Verified by Maks Vernynode 22.23.2ws 8.21.3
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny