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.
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
});
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.
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.
Related #
- Connection Lifecycle & Heartbeats — the full lifecycle these codes report on.
- Fixing WebSocket CLOSE_WAIT Accumulation — what happens when a close is observed but never acted on.
- Auto-Reconnection Strategies — the client policy that the classifier above feeds.
- Handling WebSocket Disconnects Gracefully — the UI layer of the same decision.
- Draining WebSocket Connections During Deploys — where
1001earns its keep.