Who sent WebSocket close code 1006? Close frames, 1000/1001/1011, and the codes you must never send

A 1006 in WebSocket logs isn't a code the peer sent; it means the connection ended without a Close frame. RFC 6455's closing handshake, how the code is set, code ranges, and reconnect backoff.

websocketrfc-6455networkprotocol

When a WebSocket connection drops and the log says 1006, it is tempting to read that as “the server closed the connection with code 1006.” Under RFC 6455, though, 1006 is a value neither side is allowed to put in a frame. It is a slot set aside so that an application has a number for one situation: the connection ended and no Close frame ever arrived.

This post follows RFC 6455 (December 2011), section 5.5.1 and chapter 7, and IANA’s WebSocket Close Code Number Registry (the version updated 2026-10-05, checked 2026-10-15). It does not cover how a particular browser or library exposes these values.

What goes inside a Close frame

Close is a control frame with opcode 0x8. It MAY carry a body (the “application data”). If there is a body, its first two bytes MUST be a status code: an unsigned 16-bit integer in network byte order. A UTF-8 reason string may follow.

Close frame body
+----------------------+-------------------------------+
| status code, 2 bytes | reason: UTF-8 bytes (optional) |
| e.g. 0x03E8 = 1000   |                               |
+----------------------+-------------------------------+

Every control frame MUST have a payload of 125 bytes or less and MUST NOT be fragmented. The code takes two bytes, which leaves at most 123 bytes for the reason. If every character (code point) takes three bytes in UTF-8, 41 of them use all 123 bytes. The limit is on encoded bytes, not characters, so an implementation has to check the byte length after UTF-8 encoding, not the character count.

The RFC does not define how to interpret the reason. It is not guaranteed to be human-readable, so clients MUST NOT show it to end users. Treat it as something for debugging, or for passing information to the script that opened the connection.

The order of the closing handshake

The RFC gives WebSocket its own closing procedure on top of TCP’s FIN/ACK. Its reasoning is that the TCP close is not always reliable end to end, especially when intercepting proxies sit in the path (section 1.4, non-normative). The sequence looks like this:

A                                   B
|--- Close(1000, "done") --------->|   A sends no more data frames
|                                  |   B hasn't sent Close yet, so it must reply
|<-------------- Close(1000) ------|   typically echoing the code it received
|                                  |
Both have sent and received Close → close the underlying TCP connection
(the server closes at once; the client normally waits for the server)

The requirement level of each step (section 5.5.1):

  • After sending Close, an endpoint MUST NOT send any more data frames.
  • An endpoint that receives Close without having sent one MUST send Close in response. It SHOULD do so as soon as practical, and MAY first finish sending a message that is already under way. The RFC notes that the reply typically echoes the received code. That is not a requirement.
  • There is no guarantee that an endpoint which has already sent Close will keep processing data that arrives afterwards.
  • Once an endpoint has sent and received Close, it MUST close the underlying TCP connection. The server MUST do so immediately. The client SHOULD wait for the server to close, but MAY close at any time afterwards, for example if no TCP close from the server has arrived within a reasonable period.
  • Both sides may send Close at the same time. Each has then sent and received Close, and both close the connection.

Section 7.1.1 explains why the server should close TCP first. Whichever side closes first holds the TIME_WAIT state, and if the client held it, it could not reopen that connection for 2MSL. TIME_WAIT itself is covered in Why TIME-WAIT sockets pile up.

If the TCP connection closes after the Close frames have been exchanged, the RFC says the connection was closed “cleanly” (section 7.1.4). A connection that could never be established also counts as closed, but not cleanly.

How the close code in your log is decided

This is where 1005 and 1006 come from. Section 7.1.5 defines “the WebSocket Connection Close Code” like this:

Situation This endpoint’s close code
The first Close frame received has a status code That code
The first Close frame received has no status code 1005
The connection closed and no Close frame was received at all 1006

What counts is the first Close this endpoint received, not the one it sent. The close reason follows the same rule, and it is the empty string when the received frame has no reason (section 7.1.6).

So 1006 tells you only that the connection ended before a Close frame was received. The RFC’s example is the underlying transport connection being lost. The number alone cannot tell you whether the peer process exited without sending Close or something in the network cut the connection. To find the cause you need the other endpoint’s records as well.

Nor are both sides guaranteed to see the same code. The RFC gives an example. The peer’s Close is still sitting in your receive buffer, and before your application reads it, the application independently decides to close and sends its own Close. Both endpoints have now sent and received Close, and each records the code the other one sent. When both start closing at roughly the same time, the two logs can show different codes.

The codes RFC 6455 defines

Section 7.4.1 lists codes an endpoint MAY use when sending a Close frame. Three of them are reserved values that MUST NOT be put in a frame.

Code IANA name Meaning in RFC 6455 Allowed in a frame?
1000 Normal Closure The purpose of the connection has been fulfilled Yes
1001 Going Away The endpoint is going away, e.g. a server going down or a browser navigating away from a page Yes
1002 Protocol error Terminating because of a protocol error Yes
1003 Unsupported Data Received a type of data it cannot accept (e.g. a text-only endpoint receiving a binary message) Yes
1004 Reserved Reserved; a meaning may be defined later No defined meaning
1005 No Status Rcvd Indicates that no status code was present No
1006 Abnormal Closure Indicates an abnormal close without a Close frame No
1007 Invalid frame payload data Data inconsistent with the message type (e.g. non-UTF-8 bytes in a text message) Yes
1008 Policy Violation A policy was violated; a generic code when nothing more specific fits or policy details should be hidden Yes
1009 Message Too Big A message too big to process Yes
1010 Mandatory Ext. The client expected the server to negotiate an extension and it didn’t; the needed extensions SHOULD go in the reason Yes (client only)
1011 Internal Error The server hit an unexpected condition that prevented it from fulfilling the request Yes
1015 TLS handshake Indicates a failed TLS handshake (e.g. the server certificate can’t be verified) No

A few notes:

  • The RFC explains why servers don’t use 1010: a server can fail the WebSocket handshake instead.
  • 1011 is named “Internal Server Error” in RFC 6455’s initial IANA table (section 11.7) and “Internal Error” in the current IANA registry. The registry entry cites erratum 3227 alongside RFC 6455. The description in section 7.4.1 begins with “a server”.
  • 1012 (Service Restart), 1013 (Try Again Later) and 1014 are not in the RFC 6455 text; they exist only in the IANA registry, which references posts on the IETF hybi mailing list. The registry describes 1014 as “The server was acting as a gateway or proxy and received an invalid response from the upstream server. This is similar to 502 HTTP Status Code.” For 1012 and 1013 the registry gives only a name, so this post reads nothing more into them.

Section 7.4 also says plainly that how the receiver interprets a code, and what it should do about it, is left undefined. Reconnecting on 1001 or sending the user back to log in on 1008 is a decision for your application.

Code ranges: where to put your own codes

Sections 7.4.2 and 11.7 divide the space:

Range Use Registration
0–999 Not used —
1000–2999 This protocol, its future revisions, and extensions with a public specification Standards Action, Specification Required, or IESG Review
3000–3999 Libraries, frameworks and applications Registered with IANA, First Come First Served
4000–4999 Private use Cannot be registered; used by prior agreement between applications

The RFC leaves the meaning of 3000s and 4000s undefined. In the IANA registry as updated 2026-10-05, the registered 3000-range values are 3000 (Unauthorized), 3003 (Forbidden) and 3008 (Timeout); the rest are unassigned. 1016–2999 are unassigned too.

For codes that only your own client and server use, picking from 4000–4999 and defining their meaning on both sides matches what that range is for. A library shared by many services can register codes in the 3000s with IANA.

Reconnecting after 1006: why backoff matters

Section 7.2.3 is fairly specific about reconnecting after an abnormal closure. Its concern: if the problem is not transient and every deployed client reconnects immediately and persistently, the server faces what amounts to a denial-of-service attack, and recovery can be slow or harder.

  • Clients SHOULD use some form of backoff when reconnecting.
  • The first reconnect attempt SHOULD be delayed by a random amount of time. The RFC calls a random value between 0 and 5 seconds a reasonable initial delay, and clients MAY choose a different interval.
  • If the first attempt fails, later attempts SHOULD be delayed by increasingly longer amounts of time. The example method is truncated binary exponential backoff.

Here is that advice as code. The 0–5 seconds for the first attempt is the RFC’s figure; the formula after that and the 60-second cap are choices made for this post, not values from the RFC. From the second attempt on, the delay is fixed: 5, 10, 20 and 40 seconds, doubling each time, then 60 seconds for every attempt after that.

// attempt: 0 for the first reconnect attempt
function reconnectDelayMs(attempt: number): number {
  if (attempt === 0) {
    return Math.random() * 5_000; // random, 0–5 s
  }
  // 5 s, 10 s, 20 s, 40 s, then held at 60 s
  return Math.min(60_000, 5_000 * 2 ** (attempt - 1));
}

The random first delay spreads out clients that were cut off at the same moment, so they don’t all come back at the same moment. The later delays in this example are fixed and add no further randomness.

Checklist for implementations

The rules that come straight from the RFC text, and this post’s suggestions built on them, are listed separately.

Rules from RFC 6455

  • Never put 1005, 1006 or 1015 in a Close frame.
  • Keep the reason within 123 bytes (derived from the 125-byte control frame limit).
  • Clients don’t show the reason string to users.
  • Answer a Close with a Close; once both have been exchanged, the server closes TCP right away.
  • After an abnormal closure, reconnect with a random initial delay and growing delays after that.

Suggestions from this post

  • Send 1000 for a normal close, and 1001 when the server is shutting down or being redeployed.
  • Define service-specific close reasons in 4000–4999 and keep the same table on client and server.
  • Log not just the code but whether a Close frame was actually received. When investigating 1006, match it against the other endpoint’s logs.

Back to the blog