WebSocket Close Codes Explained #

Your reconnect logic reopens the socket after every close, so logging out reopens a connection with a dead token, forever, at whatever your minimum backoff is. Or the opposite: you treat every close as final, and a client whose Wi-Fi blinked sits on a dead connection until the user reloads the page. Both bugs have the same root — the close code was never read. RFC 6455 gives you a small vocabulary for saying why a connection ended, and using it correctly is the difference between a client that recovers and one that fights you.

Root cause #

Every WebSocket close carries a numeric code and an optional UTF-8 reason under 123 bytes. The codes divide into two groups that behave very differently.

Codes 1000–1015 are defined by the protocol. Some are sent on the wire (1000, 1001, 1008, 1011, 1013), and some are never sent — they are values the local endpoint reports to its own application when no close frame arrived at all. 1006 is the important one in that second group: it means “abnormal closure, no close frame received”, and a server can never send it, only observe it. 1005 similarly means “no status code was present”.

Codes 4000–4999 are yours. The protocol reserves them for application use, and they are the right place for domain-specific reasons — session superseded, room deleted, token revoked — that no standard code describes.

The practical consequence is that 1006 and 1000 demand opposite client behaviour, and they are the two you will see most. 1000 means the close was intentional and negotiated; reconnecting is usually wrong. 1006 means the connection died without a handshake; reconnecting with backoff is exactly right.

The close codes you will actually see A table of the common WebSocket close codes, whether they travel on the wire or are reported locally, and the correct client reaction to each. The close codes you will actually see Meaning Who sends it Client should 1000 Normal we are done either side not reconnect 1001 Going Away shutdown or navigate either side reconnect promptly 1006 Abnormal no close frame local only reconnect, backoff 1008 Policy you broke a rule server stop, fix, then retry 1011 Server error unexpected failure server reconnect, backoff 1013 Try later capacity pressure server backoff hard 4001-4999 your own reasons either side per your contract 1005 and 1006 are never transmitted — they are values your own endpoint reports when no close frame arrived
Two codes decide most behaviour: 1000 means stop, 1006 means retry.

Two more rules trip people up. The code range 0–999 is invalid and will fail the connection. And 1005, 1006 and 1015 may not be sent in a close frame — passing them to close() throws in browsers and is rejected by ws.

Resolution #

Read the code on the client, classify it, and let the classification drive the reconnect decision. On the server, send a code that means something rather than defaulting to 1000 for everything.

type CloseAction = 'stop' | 'retry-now' | 'retry-backoff' | 'retry-after-auth';

const MIN_BACKOFF_MS = 1_000;
const MAX_BACKOFF_MS = 30_000;

/** Map a close event to what the client should do next. */
export function classify(event: CloseEvent): CloseAction {
switch (event.code) {
case 1000: return 'stop'; // negotiated: we asked for this
case 1001: return 'retry-now'; // server going away (deploy, restart)
case 1005: return 'retry-backoff'; // no code — treat as a network event
case 1006: return 'retry-backoff'; // abnormal: no close frame arrived
case 1008: return 'stop'; // policy violation: retrying repeats it
case 1011: return 'retry-backoff'; // server error: it may recover
case 1012:
case 1013: return 'retry-backoff'; // restart / try again later
case 4401: return 'retry-after-auth'; // ours: token expired, refresh first
case 4403: return 'stop'; // ours: not permitted, do not retry
case 4409: return 'stop'; // ours: session superseded elsewhere
default:
// Unknown 4xxx codes are ours but unhandled — be conservative and stop,
// so a future server-side code cannot cause an accidental hot loop.
return event.code >= 4000 ? 'stop' : 'retry-backoff';
}
}

export function nextDelayMs(attempt: number): number {
const ceiling = Math.min(MAX_BACKOFF_MS, MIN_BACKOFF_MS * 2 ** attempt);
return Math.random() * ceiling; // full jitter, so clients do not sync up
}

On the server, be explicit. A deploy drain should send 1001 so clients reconnect immediately into the new pods rather than backing off; a rate-limit close should send 1008 so a client stops instead of hammering; a capacity shed should send 1013 so clients back off hard.

// Draining for a rolling deploy: 1001 means "come back, just not to me".
process.on('SIGTERM', () => {
for (const socket of wss.clients) socket.close(1001, 'server draining');
setTimeout(() => process.exit(0), 5_000); // grace period for close frames to flush
});
A clean close versus an abnormal one In a clean close the server sends a close frame with a code and the client echoes it, so both sides know why; in an abnormal close no frame arrives and the client synthesises code 1006 locally. A clean close versus an abnormal one Client Server close frame 1001 'draining' close frame echoed back onclose code 1001, wasClean true connection cut — no frame onclose code 1006, wasClean false wasClean distinguishes the two without inspecting the code at all
The presence of a close frame is what separates a decision from an accident.

Codes matter most during a deploy, where the difference between 1001 and a bare socket teardown decides whether reconnects are immediate or delayed by a backoff window nobody needed.

A rolling deploy, told through close codes With an explicit 1001 close, clients reconnect within seconds of a drain; without it they observe 1006 and wait out a backoff window before trying again. A rolling deploy, told through close codes drain window avoidable delay if the code is omitted SIGTERM to pod (0s) close 1001 to all sockets (1s) clients reconnect at once (3s) pod exits, no 1006 seen (6s) without 1001: clients wait out backoff (20s) The code is the only thing that tells a client the difference between a planned drain and a network failure
Sending 1001 turns a twenty-second reconnect gap into a three-second one, with no client change required.

Verification #

Force each path deliberately and confirm the client does the right thing.

# Watch the close frame on the wire, including the code (first two payload bytes)
tcpdump -i lo -X 'tcp port 8080' | grep -A2 '0x88'

# Drive a close from a test client and print what the browser reports
node -e "const W=require('ws');const s=new W('ws://localhost:8080/ws');s.on('close',(c,r)=>console.log('code',c,'reason',String(r)))"

In the browser, the DevTools Network panel shows the close frame with its code in the Messages tab, and event.wasClean in your handler tells you whether a frame arrived at all. The behavioural assertions worth automating: after a 1000 the client opens zero new sockets, after a 1006 it opens exactly one after the backoff window, and after a 1008 it stops and surfaces an error to the user.

Operational checklist #

FAQ #

Why do I see 1006 so often? #

Because it is what the browser reports whenever the connection ends without a close frame — a dropped Wi-Fi connection, a proxy idle timeout, a laptop suspending, a server process killed rather than shut down. It is not an error in your code by itself. A background rate of 1006 is normal; a spike in it usually points at a proxy timeout shorter than your heartbeat interval, which the connection lifecycle and heartbeats guide covers.

Can the server send 1006 to tell the client something went wrong? #

No. 1006 is explicitly reserved and may never appear in a close frame; endpoints synthesise it locally. If the server wants to say “something failed unexpectedly”, the code is 1011. If it wants to say “I am shutting down”, it is 1001. If it wants to say “you are being rate limited”, it is 1008, as used in rate limiting WebSocket messages per client.

What is the difference between close() and terminate()? #

close() starts the closing handshake: it sends a close frame with your code and waits for the peer to echo it, which is what lets the other side learn why. terminate() destroys the underlying socket immediately with no frame at all — the peer sees 1006. Use close() whenever the peer is still responsive, and terminate() only for peers that have stopped answering, where waiting for a handshake accomplishes nothing.

Should custom codes start at 4000 or 3000? #

Use 4000–4999. The 3000–3999 range is reserved for registration with IANA by libraries and frameworks, so picking a number there risks colliding with a library you adopt later. The 4000 range is explicitly for private application use and nothing will ever be registered in it.

Does the reason string reach the client? #

Yes, as event.reason, provided the close was clean. It is limited to 123 bytes of UTF-8 — the close frame payload is capped at 125 bytes and the code takes two — and it is not a place for structured data or anything sensitive, since it travels in a frame that intermediaries may log. Use it for a short human-readable hint and put anything richer in a normal message sent before the close.

Back to Connection Lifecycle & Heartbeats