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

// 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));
  }
});
// 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);
};
// 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

  1. Step 1.

    Start the target in its own terminal and leave it running.

    node wsprobe.mjs 9481
    
    22:40:54.395 listening on 9481
  2. 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"; done
    
        58 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=true

    All three arrive with the reason intact and wasClean=true, because each came from a close frame the server sent.

  3. 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
  4. 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, leaving

    88 is FIN plus opcode 8, a close frame. 06 is the payload length. 03 e8 is 1000 as a big-endian 16-bit integer, and 64 6f 6e 65 is done. 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.mjs exits instead of replying, and recorded 1006 for the same socket.

  5. 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 frame

    No frame of any kind. The TCP connection ends and the client fills in 1006 by itself.

  6. 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 sent

    Client 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

Sign: A dashboard counts 1006 as a server-sent close code and groups it with 1000 and 1011.Cause: 1006 is never transmitted. RFC 6455 section 7.4.1 forbids it in a close frame, and the client API rejects it with InvalidAccessError, as step 6 shows. Every 1006 in a metric is a local guess by whichever peer noticed the socket die.
Sign: The client logs 1000 and the server logs 1006 for the same connection, so the two reports are treated as different incidents.Cause: The close handshake has two halves. A peer that receives a close frame and then disappears without echoing one leaves the other side with no frame to read, which is the definition of 1006. Step 4 produces this on purpose.
Sign: A test that tries to reproduce a 1001 from browser code silently does nothing.Cause: The browser WebSocket API accepts 1000 and the 3000 to 4999 private range only. Calls with 1001, 1005, 1006 or 1011 throw InvalidAccessError, so the socket stays open and the assertion that follows measures the wrong state.
Sign: An empty close reason is read as a server that forgot to set one.Cause: A reason string can only travel inside a close frame. When the code is 1006 there was no frame, so the reason is empty by construction and says nothing about the server's configuration.

What to check next

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.

intermediate8 minpublished updated Maks Verny