websocket
Why Your WebSocket Keeps Disconnecting: Reading Close Codes 1000–1015
Code 1006 with an empty reason is the most common WebSocket failure and the least explained. How the close handshake actually works, the full RFC 6455 close-code table, which codes are never allowed on the wire, and why an idle proxy is probably killing your connection at the 60-second mark.
Your realtime feature works perfectly in dev, ships, and then the connection starts dying in production — always with the same useless-looking evidence: onclose fires, event.code is 1006, event.reason is an empty string, and wasClean is false. Here’s the thing that makes it stop being mysterious: a WebSocket close code isn’t an error message, it’s a report on whether a specific two-frame handshake completed — and 1006 means the handshake never happened at all. The connection didn’t close; it vanished. Once you know the handshake, every code in the registry sorts itself into “someone told you why” versus “nobody got the chance to” — and 1006, the one everybody hits, is the entire second category.
The close handshake nobody thinks about
TCP connections just end. WebSocket added a goodbye ritual on top, defined in RFC 6455: a Close frame (opcode 0x8) whose payload optionally carries a 2-byte unsigned status code plus a short UTF-8 reason string. (Control frames are capped at 125 payload bytes, so after the code there’s room for at most 123 bytes of reason — your eloquent shutdown message gets 123 bytes, total.)
The handshake is an echo: whichever side wants out sends a Close frame, and the RFC is blunt about the other side’s obligation — “If an endpoint receives a Close frame and did not previously send a Close frame, the endpoint MUST send a Close frame in response.” Once both sides have sent and received one, the connection is considered closed and the underlying TCP connection is torn down (the server is supposed to do the TCP teardown first).
That two-frame exchange is exactly what the browser summarizes for you in the CloseEvent:
socket.onclose = (event) => {
console.log(event.code); // 1000–4999, or 1006 if nothing arrived
console.log(event.reason); // the peer's UTF-8 reason string, often ''
console.log(event.wasClean); // true only if the close handshake completed
};
wasClean is the honest one: true means the ritual completed, false means the connection died mid-sentence. Per MDN, reason is whatever the server chose to say — it’s free text, specific to that server, and very often empty even on clean closes.
The close-code registry, in full
RFC 6455 §7.4.1 defines the base set; IANA maintains the registry that later additions (1012–1014, proposed on the HyBi working-group mailing list) were added to. The column that explains most confusion is the last one — three of these codes are reserved and MUST NOT ever be sent in a Close frame. They exist purely so client APIs have a number to hand your code when there was no frame to read a number from.
| Code | Name | What it means | Source | Allowed on the wire? |
|---|---|---|---|---|
| 1000 | Normal Closure | The purpose of the connection has been fulfilled. The boring, correct goodbye. | RFC 6455 | Yes |
| 1001 | Going Away | An endpoint is leaving — server shutting down, or browser navigating away from the page. | RFC 6455 | Yes |
| 1002 | Protocol Error | The peer terminated because of a WebSocket protocol violation. | RFC 6455 | Yes |
| 1003 | Unsupported Data | The peer got a frame type it can’t accept (e.g. binary sent to a text-only endpoint). | RFC 6455 | Yes |
| 1004 | — | Reserved; no meaning defined. | RFC 6455 | No — MUST NOT be sent |
| 1005 | No Status Received | A Close frame arrived, but its payload carried no status code. | RFC 6455 | No — MUST NOT be sent |
| 1006 | Abnormal Closure | The connection died with no Close frame at all. | RFC 6455 | No — MUST NOT be sent |
| 1007 | Invalid Frame Payload Data | Message data inconsistent with its type — classically non-UTF-8 bytes in a text frame. | RFC 6455 | Yes |
| 1008 | Policy Violation | Generic “you broke my rules” when 1003 or 1009 don’t fit. | RFC 6455 | Yes |
| 1009 | Message Too Big | A message too large for the endpoint to process. | RFC 6455 | Yes |
| 1010 | Mandatory Extension | Sent by a client: the server didn’t negotiate an extension the client required. | RFC 6455 | Yes |
| 1011 | Internal Error | Sent by a server: an unexpected condition prevented it from fulfilling the request. | RFC 6455 | Yes |
| 1012 | Service Restart | The server is restarting; reconnect is welcome. | IANA (HyBi list) | Yes |
| 1013 | Try Again Later | Temporary condition — e.g. the server is overloaded. | IANA (HyBi list) | Yes |
| 1014 | Bad Gateway | A gateway/proxy got an invalid response upstream — WebSocket’s HTTP 502. | IANA (HyBi list) | Yes |
| 1015 | TLS Handshake | The TLS handshake failed (e.g. the certificate couldn’t be verified). | RFC 6455 | No — MUST NOT be sent |
Beyond the table: 1016–2999 are reserved for future protocol-level use, 3000–3999 are for libraries and frameworks to register with IANA, and 4000–4999 are private-use — free for your app and your server to assign meanings by mutual agreement. That last range is genuinely useful: closing with socket.close(4001, 'auth token expired') gives your client something machine-readable to branch on, which the standard codes never will.
1006 is not a close code — it’s the absence of one
Look at the reserved rows again. 1005, 1006, and 1015 can never legally cross the network, which means when you see 1006, nobody sent you 1006. Your browser synthesized it locally to report: the TCP connection is gone and no Close frame ever arrived. Something below the WebSocket layer pulled the plug — the process died, the network dropped, or a middlebox silently discarded the connection.
There’s a second reason 1006 dominates, and it’s deliberate. The WHATWG spec requires that when a connection attempt fails — wrong port, dead host, refused upgrade, TLS failure, anything — the browser fires a bare error event and closes with 1006, revealing nothing more. The spec’s rationale: “Allowing a script to distinguish these cases would allow a script to probe the user’s local network in preparation for an attack.” A page that could read “connection refused” versus “timed out” versus “TLS error” could port-scan your intranet from JavaScript. So every failure is flattened into the same shape. (This is also why a browser almost never shows you 1015 — a TLS handshake failure is just another failed connection, reported as 1006; 1015 mostly appears in non-browser clients and logs.) Frustrating when debugging, but it’s protecting the humans running your code.
So which real-world event was it? In rough order of likelihood:
| Cause | Signature |
|---|---|
| Idle timeout at a proxy or load balancer | Dies after a suspiciously round interval — usually 60 seconds — but only when no traffic is flowing. Nginx’s proxy_read_timeout and AWS ALB’s idle timeout both famously default to 60s. |
| Missing keepalives | Same as above: the connection is fine while chatty, dead after quiet periods. |
| Server crash or hard restart | All clients drop at once; a graceful deploy would have sent 1001 or 1012 instead. |
| Client network change | Wi-Fi → cellular, laptop lid closed, elevator. The close fires late or when the OS notices. |
| Connection never opened at all | onclose fires almost immediately after new WebSocket(...), right after an error event. |
The first two are the same disease. A proxy holding an idle TCP connection eventually reclaims it, and it does not send your WebSocket a polite Close frame on the way out — it just stops forwarding bytes. Sixty quiet seconds later: 1006.
Ping/pong: the heartbeat the protocol already has
RFC 6455 built the fix in. Ping frames (0x9) and Pong frames (0xA) exist precisely to keep traffic flowing and to detect dead peers: on receiving a Ping, an endpoint “MUST send a Pong frame in response” — and the Pong must echo the Ping’s payload byte-for-byte, so you can match responses to requests. The RFC even blesses unsolicited Pongs as a one-way heartbeat that requires no reply.
The catch for web developers: the browser WebSocket API cannot send a Ping. There’s no socket.ping() — the interface gives you send() and close(), full stop. Browsers answer incoming Pings automatically (invisibly — they never surface in onmessage), but the initiative has to come from your server, where libraries expose it directly (e.g. ws.ping() in Node’s ws). Server-initiated pings every 20–30 seconds solve the idle-proxy problem for every client at once, which is why that’s the correct place to fix it.
If you don’t control the server, the client-side fallback is an application-level heartbeat — an ordinary message the server agrees to ignore or echo:
function connect(url) {
const socket = new WebSocket(url);
let heartbeat;
socket.onopen = () => {
// Keep an intermediary from ever seeing 60 idle seconds.
heartbeat = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send('{"type":"ping"}');
}
}, 25_000);
};
socket.onclose = (event) => {
clearInterval(heartbeat);
if (event.code === 1000 || event.code === 1001) return; // deliberate
// 1006 and friends: reconnect with backoff, never a tight loop.
setTimeout(() => connect(url), 1000 + Math.random() * 4000);
};
}
Note what the handler branches on: the code, not the reason. Reasons are prose; codes are protocol.
The quiet cousin: 1005
1005 confuses people because it looks like 1006’s twin but describes the opposite situation. With 1005, the close handshake worked — a Close frame arrived — it just had an empty payload, so there was no status code to report. wasClean is true. That’s not a failure; plenty of servers close without stating a code, and calling socket.close() with no arguments does exactly the same thing in the other direction. 1006 means “no goodbye”; 1005 means “a goodbye with no stated cause.”
wss://, CSP, and the block you can’t see
One more way to manufacture an instant 1006: Content Security Policy governs WebSocket connections through connect-src, and a blocked connection fails exactly like a dead host — error event, close code 1006, plus a violation note in the console that’s easy to miss under a pile of red.
We ran into the mechanics of this first-hand when building our own tester. This site’s CSP declares connect-src 'self' https: — no wss: anywhere — and yet wss:// connections from the tool work, because modern browsers establish the WebSocket handshake through Fetch with the URL scheme mapped to HTTP (ws: → http:, wss: → https:), so the https: source covers it. But don’t lean on that: CSP’s own scheme-matching rules are asymmetric (an explicit ws: source matches wss: URLs, but the spec never promises https: matches wss:), so the portable move is to name your endpoint outright — connect-src 'self' wss://api.example.com. If your socket dies the instant it’s created and only on the deployed site, check the console for a CSP violation before you blame the network. Our CSP explainer covers connect-src and the rest of the directive family.
Related trap, no CSP required: an https:// page cannot open an insecure ws:// connection to a non-localhost host — that’s mixed content, blocked outright. Secure page, secure socket.
Watching a close happen, honestly
All of this is much easier to internalize when you can see it. Our WebSocket tester runs entirely in your browser: point it at a ws:// or wss:// endpoint (with optional subprotocols), and it logs every message with timestamps and byte counts — text pretty-printed as JSON if it parses, binary shown as a hex preview. The part relevant to this post is the teardown: when the connection ends, the tool reports the close code, its registry name, the server’s reason string if one came, and explicitly flags “not a clean close” whenever wasClean is false — so a proxy timeout (1006, no reason, unclean) looks visibly different from a server saying goodbye properly (1000, clean). It also warns you up front about the mixed-content case above, and when a connection fails it tells you the browser is hiding the details on purpose — because now you know it is.
Two neighbors worth knowing: the WebSocket upgrade begins life as an ordinary HTTP request, so when a handshake is being rejected, probing the same URL’s HTTP behavior with the HTTP request builder can reveal an auth wall or a proxy that doesn’t speak upgrades. And if your “realtime” need is actually server-to-server event delivery, you may want webhooks instead — no long-lived connection to keep alive at all.
The short version
| You see | It means |
|---|---|
1000 / 1001, wasClean: true | A deliberate, completed close handshake. Not a bug. |
1005, wasClean: true | Clean close, no stated cause. Also usually not a bug. |
| 1006, dies after ~60 idle seconds | An intermediary’s idle timeout. Add server pings or a heartbeat. |
| 1006, instantly, on the deployed site only | Check the console — likely CSP connect-src or mixed content. |
| 1006, all clients at once | Your server stopped without saying goodbye. |
| 1002 / 1007 / 1009 | Your frames or payloads are the problem — the peer told you so. |
| 4000–4999 | Your own application’s vocabulary. Go read your server code. |