The WebSocket Protocol

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

The WebSocket protocol turns one HTTP request into a long-lived, full-duplex, message-oriented channel between a client and a server. This page explains it from the bytes up: the HTTP handshake that starts it, the binary frames that carry messages, the masking rule that protects intermediaries, the control frames that keep the channel alive and end it cleanly, and the extension mechanism that adds compression. It is the foundation under every server stack and every browser API described in the rest of this section.

Baseline, checked on 2026-10-24: the protocol is RFC 6455 (December 2011, protocol version 13, Proposed Standard); compression is RFC 7692 (permessage-deflate); WebSocket over HTTP/2 is RFC 8441 and over HTTP/3 is RFC 9220; the browser API is the WHATWG WebSockets living standard; the registries live at IANA. The examples run on Node.js 24.21.0 with ws 8.22.0 (the current release of the library) and use no other dependency. All of them were run for real while writing this page, and the hex dumps below are their actual output.

The running scenario is Chatterbox: browsers connect to wss://chat.example.test/ws, offer the subprotocol chatterbox.v1, and exchange one JSON envelope per text frame (the envelope is defined in Message Design and Serialization). The Huddle videoconference uses the same protocol for signalling, and Pixel Arena uses binary frames for snapshots and inputs.

Where the protocol fits

A WebSocket connection is a TCP connection (usually inside TLS) that starts life as an HTTP/1.1 request, switches protocol with Upgrade: websocket, and from then on carries frames instead of HTTP messages. Three properties explain why chat, videoconference signalling and games all use it:

  • Server push without polling. Either side can send at any time, so a chat message, an ICE candidate or a game snapshot arrives as soon as it exists.

  • Message framing on top of a byte stream. TCP delivers bytes. WebSocket delivers whole messages, each tagged text (UTF-8) or binary, so the application does not invent a delimiter.

  • Same infrastructure as the web. It uses ports 80 and 443, the Origin header, cookies and TLS certificates of the site, so it passes through most firewalls and can be served by the same load balancer.

What it does not give you: ordering across connections, delivery acknowledgements, reconnection, message types or rooms. TCP gives in-order, reliable delivery on one connection, with the head-of-line blocking that implies for games (see Networking Fundamentals); everything else is application protocol, as in Chatterbox. The URL schemes are ws:// (port 80) and wss:// (port 443, TLS); production traffic always uses wss://.

The opening handshake

The client sends an ordinary HTTP/1.1 GET with a handful of required headers. The server answers 101 Switching Protocols and, after that blank line, both sides speak frames. This is the exact exchange captured from the raw-socket client later on this page, against a ws server that allow-lists the origin:

GET /ws HTTP/1.1
Host: 127.0.0.1:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: IDodcvidpz8SPk/RN34AHQ==
Sec-WebSocket-Version: 13
Origin: https://chat.example.test
Sec-WebSocket-Protocol: chatterbox.v2, chatterbox.v1
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: gDlexXiobjOuP68N0pwDwYObskk=
Sec-WebSocket-Protocol: chatterbox.v1
Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover; server_max_window_bits=10

The headers have precise roles:

Header Sent by Meaning

Upgrade: websocket and Connection: Upgrade

Both

Ask for, and confirm, the protocol switch. The request must be a GET with HTTP/1.1 or later.

Sec-WebSocket-Key

Client

16 random bytes, base64 encoded (24 characters). Fresh for every handshake.

Sec-WebSocket-Accept

Server

Proof that the server understood the WebSocket handshake (computed below).

Sec-WebSocket-Version

Client

Must be 13. If the server speaks other versions it answers 426 Upgrade Required with its own Sec-WebSocket-Version list.

Origin

Browser clients

The web origin of the page that opened the socket. Browsers always send it. Non-browser clients may omit it.

Sec-WebSocket-Protocol

Client offers a list, server picks one

Application subprotocols, in preference order.

Sec-WebSocket-Extensions

Client offers, server accepts a subset

Protocol extensions and their parameters, such as permessage-deflate.

The Sec- prefix matters: a page’s JavaScript cannot set any header starting with it, so a cross-origin page cannot forge a handshake with fetch() or XMLHttpRequest. The browser WebSocket API also cannot add arbitrary headers such as Authorization, which is why Chatterbox authenticates with a cookie or a one-time ticket and not with a bearer header (see Authentication and Authorization).

Computing Sec-WebSocket-Accept by hand

The accept value is deliberately simple. Take the client’s Sec-WebSocket-Key text exactly as sent, append the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, compute the SHA-1 digest of that string and base64-encode the 20 bytes. It has no security value against an attacker who can read the traffic. Its job is to make sure that a server (or an HTTP cache that is not a WebSocket server) that never read the request cannot accidentally produce a valid response, and that a client cannot be tricked into treating some other HTTP response as a WebSocket handshake.

// Computes Sec-WebSocket-Accept by hand: SHA-1(key + GUID), base64.
import { createHash, randomBytes } from 'node:crypto';

const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

export function acceptFor(key) {
  return createHash('sha1').update(key + GUID).digest('base64');
}

export function newKey() {
  return randomBytes(16).toString('base64');
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const sample = 'dGhlIHNhbXBsZSBub25jZQ==';
  const expected = 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=';
  const got = acceptFor(sample);
  console.log('key     :', sample);
  console.log('accept  :', got);
  console.log('matches RFC 6455 section 1.3:', got === expected);
  const fresh = newKey();
  console.log('fresh key:', fresh, '->', acceptFor(fresh));
}

Running node accept.mjs prints:

key     : dGhlIHNhbXBsZSBub25jZQ==
accept  : s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
matches RFC 6455 section 1.3: true
fresh key: cxkMsZfl+I+qrOvxwzMLXQ== -> +xbzfurQcZTXQtdJAKWT3x5F5OM=

The first pair is the sample of RFC 6455, section 1.3, so the function is checked against the standard and not only against itself.

What the client and server must check

The client must fail the connection if the status is not 101, if Upgrade or Connection are missing, if Sec-WebSocket-Accept is wrong, if the response carries a Sec-WebSocket-Extensions value it did not offer, or if Sec-WebSocket-Protocol names a subprotocol it did not offer. In a browser, every one of those failures looks the same to JavaScript: an error event followed by a close event with code 1006 and no reason, because the standard deliberately hides handshake details from scripts. The reason is in the DevTools Network panel (see Inspecting WebSocket traffic). A server that does not like the request refuses it with an ordinary HTTP status (400 for a malformed handshake, 401 or 403 for authentication and origin failures, 426 for a wrong version) before any WebSocket exists.

The versions are simple: 13 is the only one in use, defined by RFC 6455. The older values in the IANA version registry belong to pre-standard drafts and to the abandoned Hixie protocol. A server that sees another version must answer 426 and list 13.

Handshake and close in one picture

sequenceDiagram participant B as Browser (Chatterbox web client) participant S as Server (ws on Node.js) B->>S: GET /ws, Upgrade websocket, Sec-WebSocket-Key, Origin, Sec-WebSocket-Protocol chatterbox.v1 Note over S: check Origin and path, pick subprotocol, SHA-1 of key plus GUID S-->>B: 101 Switching Protocols, Sec-WebSocket-Accept, Sec-WebSocket-Protocol chatterbox.v1 Note over B,S: from here on only frames, TCP stays open B->>S: text frame (masked): hello envelope S->>B: text frame (unmasked): ack envelope S->>B: ping (hb) B-->>S: pong (hb) B->>S: close frame, code 1000 Note over B: sends no more data frames S->>B: close frame, code 1000 Note over S: server closes TCP first S--xB: TCP FIN

The frame format

After the handshake every message is sent as one or more frames. The header is two bytes for short messages and grows to 14 bytes. The figure shows the layout in 32-bit rows, as RFC 6455 draws it.

WebSocket frame header bit layout in 32-bit rows with FIN, RSV1-3, opcode, MASK, payload length, extended length, masking key and payload

The fields, in order:

  • FIN (1 bit) is 1 on the final (or only) frame of a message.

  • RSV1, RSV2, RSV3 (1 bit each) must be 0 unless an extension that was negotiated in the handshake gives them a meaning. permessage-deflate uses RSV1. A receiver must fail the connection (close 1002) when it sees a reserved bit it did not negotiate.

  • opcode (4 bits) tells what the frame carries:

    Opcode Name Notes

    0x0

    Continuation

    A later fragment of a fragmented text or binary message.

    0x1

    Text

    Payload is UTF-8. The complete message must be valid UTF-8, or the receiver closes with 1007.

    0x2

    Binary

    Arbitrary bytes. Pixel Arena snapshots use it.

    0x8

    Close

    Starts or answers the closing handshake.

    0x9

    Ping

    Liveness probe, may carry up to 125 bytes.

    0xA

    Pong

    Answer to a ping, or a one-way heartbeat.

    0x3-0x7, 0xB-0xF

    Reserved

    Undefined. A receiver that sees one fails the connection.

  • MASK (1 bit) says that a 4-byte masking key follows the length and that the payload is XOR-masked.

  • payload length (7 bits, plus up to 64) is encoded in one of three forms. A value from 0 to 125 is the length itself. The value 126 means that the next 2 bytes (unsigned, big-endian) hold the length, which therefore runs from 126 to 65,535. The value 127 means that the next 8 bytes hold the length (the most significant bit must be 0). A sender must use the shortest form.

  • masking key (32 bits) is present only when MASK is 1.

  • payload data is the application data, preceded by extension data if an extension defines any.

Control frames (opcodes 8 to 10) have two extra rules: the payload length must be 125 or less, so they always use the 7-bit form, and they must not be fragmented.

Hand-decoding frames

The following module encodes and decodes frames exactly as described above. It is the code used by the other examples, and it is short enough to read as documentation of the format:

// Hand-written RFC 6455 frame encoder/decoder (no library).
import { randomBytes } from 'node:crypto';

export const OP = { CONT: 0x0, TEXT: 0x1, BINARY: 0x2, CLOSE: 0x8, PING: 0x9, PONG: 0xa };

export function encodeFrame({ fin = true, rsv = 0, opcode, payload = Buffer.alloc(0), mask = false, maskKey }) {
  const len = payload.length;
  let ext;
  let len7;
  if (len < 126) { len7 = len; ext = Buffer.alloc(0); }
  else if (len <= 0xffff) { len7 = 126; ext = Buffer.alloc(2); ext.writeUInt16BE(len); }
  else { len7 = 127; ext = Buffer.alloc(8); ext.writeBigUInt64BE(BigInt(len)); }

  const head = Buffer.alloc(2);
  head[0] = (fin ? 0x80 : 0) | ((rsv & 0x7) << 4) | (opcode & 0xf);
  head[1] = (mask ? 0x80 : 0) | len7;

  if (!mask) return Buffer.concat([head, ext, payload]);
  const key = maskKey ?? randomBytes(4);
  const masked = Buffer.from(payload);
  for (let i = 0; i < masked.length; i++) masked[i] ^= key[i % 4];
  return Buffer.concat([head, ext, key, masked]);
}

// Returns { frame, consumed } or null when the buffer holds an incomplete frame.
export function decodeFrame(buf) {
  if (buf.length < 2) return null;
  const fin = (buf[0] & 0x80) !== 0;
  const rsv = (buf[0] >> 4) & 0x7;
  const opcode = buf[0] & 0xf;
  const masked = (buf[1] & 0x80) !== 0;
  let len = buf[1] & 0x7f;
  let off = 2;
  if (len === 126) {
    if (buf.length < 4) return null;
    len = buf.readUInt16BE(2); off = 4;
  } else if (len === 127) {
    if (buf.length < 10) return null;
    len = Number(buf.readBigUInt64BE(2)); off = 10;
  }
  let key;
  if (masked) {
    if (buf.length < off + 4) return null;
    key = buf.subarray(off, off + 4); off += 4;
  }
  if (buf.length < off + len) return null;
  const payload = Buffer.from(buf.subarray(off, off + len));
  if (masked) for (let i = 0; i < payload.length; i++) payload[i] ^= key[i % 4];
  return { frame: { fin, rsv, opcode, masked, maskKey: key, payload }, consumed: off + len };
}

export function closePayload(code, reason = '') {
  const r = Buffer.from(reason, 'utf8');
  const b = Buffer.alloc(2 + r.length);
  b.writeUInt16BE(code);
  r.copy(b, 2);
  return b;
}

export const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join(' ');

The next script opens a socket with fragments.mjs and sends one frame in each length form, then a fragmented message with a ping in the middle, then an oversized frame. It is written against the ws server shown later on this page, which has maxPayload set to 16384 bytes, the Chatterbox frame limit.

// Length forms, fragmentation with an interleaved control frame, and the 1009 limit, on a raw socket.
import net from 'node:net';
import { newKey } from './accept.mjs';
import { OP, encodeFrame, decodeFrame, hex } from './frames.mjs';

const sock = net.connect(8080, '127.0.0.1');
let buf = Buffer.alloc(0);
let upgraded = false;
const fixedKey = Buffer.from([0x37, 0xfa, 0x21, 0x3d]); // the masking key used in RFC 6455 section 5.7

const send = (label, o) => {
  const f = encodeFrame({ mask: true, maskKey: fixedKey, ...o });
  console.log(`>>> ${label}: ${hex(f.subarray(0, 14))}${f.length > 14 ? ' ...' : ''} (${f.length} bytes)`);
  sock.write(f);
};

sock.on('connect', () => sock.write([
  'GET /ws HTTP/1.1', 'Host: 127.0.0.1:8080', 'Upgrade: websocket', 'Connection: Upgrade',
  `Sec-WebSocket-Key: ${newKey()}`, 'Sec-WebSocket-Version: 13',
  'Origin: http://localhost:8080', 'Sec-WebSocket-Protocol: chatterbox.v1', '', '',
].join('\r\n')));

// The 64-bit form is only printed, not sent (70000 bytes would hit the same limit).
console.log('64-bit length header for 70000 bytes:', hex(encodeFrame({ opcode: OP.BINARY, payload: Buffer.alloc(70000), mask: true, maskKey: fixedKey }).subarray(0, 14)));

sock.on('data', (chunk) => {
  buf = Buffer.concat([buf, chunk]);
  if (!upgraded) {
    const end = buf.indexOf('\r\n\r\n');
    if (end < 0) return;
    buf = buf.subarray(end + 4);
    upgraded = true;
    send('7-bit length  (5 bytes)', { opcode: OP.TEXT, payload: Buffer.from('Hello') });
    send('16-bit length (300 bytes)', { opcode: OP.TEXT, payload: Buffer.alloc(300, 0x61) });
    // One message in three fragments, with a ping between them.
    send('fragment 1: TEXT fin=0', { fin: false, opcode: OP.TEXT, payload: Buffer.from('Chatter') });
    send('control frame in between: PING', { opcode: OP.PING, payload: Buffer.from('mid') });
    send('fragment 2: CONT fin=0', { fin: false, opcode: OP.CONT, payload: Buffer.from('box ') });
    send('fragment 3: CONT fin=1', { fin: true, opcode: OP.CONT, payload: Buffer.from('rocks') });
    send('16-bit length (20000 bytes > maxPayload 16384)', { opcode: OP.BINARY, payload: Buffer.alloc(20000) });
  }
  for (;;) {
    const r = decodeFrame(buf);
    if (!r) break;
    buf = buf.subarray(r.consumed);
    const f = r.frame;
    const name = Object.keys(OP).find((k) => OP[k] === f.opcode);
    const code = f.opcode === OP.CLOSE ? ` code=${f.payload.readUInt16BE(0)}` : '';
    const text = f.opcode === OP.CLOSE ? '' : ` len=${f.payload.length} ${JSON.stringify(f.payload.toString().slice(0, 24))}`;
    console.log(`<<< ${name} fin=${f.fin}${code}${text}`);
  }
});
sock.on('close', () => console.log('socket closed'));

The output is below, with the bytes of the sent frames annotated. The masking key 37 fa 21 3d is the example key of RFC 6455, section 5.7, reused only so that the dump is reproducible. Real clients never repeat a key.

64-bit length header for 70000 bytes: 82 ff 00 00 00 00 00 01 11 70 37 fa 21 3d
>>> 7-bit length  (5 bytes): 81 85 37 fa 21 3d 7f 9f 4d 51 58 (11 bytes)
>>> 16-bit length (300 bytes): 81 fe 01 2c 37 fa 21 3d 56 9b 40 5c 56 9b ... (308 bytes)
>>> fragment 1: TEXT fin=0: 01 87 37 fa 21 3d 74 92 40 49 43 9f 53 (13 bytes)
>>> control frame in between: PING: 89 83 37 fa 21 3d 5a 93 45 (9 bytes)
>>> fragment 2: CONT fin=0: 00 84 37 fa 21 3d 55 95 59 1d (10 bytes)
>>> fragment 3: CONT fin=1: 80 85 37 fa 21 3d 45 95 42 56 44 (11 bytes)
>>> 16-bit length (20000 bytes > maxPayload 16384): 82 fe 4e 20 37 fa 21 3d 37 fa 21 3d 37 fa ... (20008 bytes)
<<< TEXT fin=true len=5 "Hello"
<<< TEXT fin=true len=300 "aaaaaaaaaaaaaaaaaaaaaaaa"
<<< PONG fin=true len=3 "mid"
<<< TEXT fin=true len=16 "Chatterbox rocks"
<<< CLOSE fin=true code=1009
socket closed

Reading the first frame byte by byte:

81            1000 0001  FIN=1, RSV=000, opcode=0x1 (text)
85            1000 0101  MASK=1, payload length=5
37 fa 21 3d   masking key
7f 9f 4d 51 58  payload XOR key: 48^37=7f ('H'), 65^fa=9f ('e'), 6c^21=4d ('l'), 6c^3d=51 ('l'), 6f^37=58 ('o')

The same message from the server is not masked and is seven bytes long: 81 05 48 65 6c 6c 6f. The 300-byte frame starts 81 fe 01 2c: fe is MASK plus length 126, and 01 2c is 300. The 70000-byte header starts 82 ff and the 8-byte length 00 00 00 00 00 01 11 70 (0x11170 is 70000). Because the frame header and the payload can be split across TCP segments arbitrarily, a decoder must buffer until a whole frame is available, which is what the null return of decodeFrame expresses.

Masking, and why it exists

Every frame sent from a client to a server must be masked with a fresh 32-bit key, and every frame sent from a server to a client must not be. The payload byte at position i is XOR-ed with byte i mod 4 of the key. Applying the same operation again restores the original, so the receiver uses the same loop. A server must close the connection (code 1002) when it receives an unmasked client frame, and a client must do the same for a masked server frame.

The purpose is not confidentiality, because the key travels in the clear next to the data. The purpose is to defeat cache-poisoning attacks through intermediaries. A malicious page could otherwise open a WebSocket and send bytes that look like an HTTP request, for example GET /app.js followed by headers, through a transparent proxy that does not understand the upgrade. The proxy would see what looks like a new request on that connection, fetch the attacker’s chosen URL and cache the answer under the victim site’s URL. Masking with a key the script cannot choose or predict means the attacker cannot control the exact bytes on the wire, so it cannot craft such a request. Two consequences:

  • The key must come from a cryptographically strong source and must be unpredictable. Math.random() is not enough. (The examples use crypto.randomBytes.)

  • Masking is not encryption. Anyone who sees the traffic can unmask it. Use wss:// for confidentiality and integrity.

The cost is a XOR pass over every client payload, which servers do in optimised native code (ws uses an optional native addon or a vectorised JavaScript fallback). It is one of the reasons client-to-server throughput is slightly lower than server-to-client throughput.

Fragmentation and message boundaries

A message may be split into several frames. The first frame carries the real opcode (text or binary) with FIN 0, the following frames carry opcode 0 (continuation) with FIN 0, and the last one carries opcode 0 with FIN 1. The rules are:

  • Fragments of different messages must not interleave. There is only one message in flight in each direction.

  • Control frames may be injected between fragments. A ping or a close can arrive in the middle of a long message and must be handled immediately. The fragments.mjs run above shows a pong coming back before the message completes.

  • Text fragments may split a UTF-8 character. Validation applies to the whole message, so a receiver cannot validate each fragment alone.

  • There is no limit in the protocol on message size, so the application sets one. Without a limit, a client can send fragments forever and exhaust memory. ws exposes maxPayload (default 100 MiB, which is far too large for chat); Chatterbox sets 16384 bytes, and an oversized message is answered with close code 1009, as the run above shows.

Fragmentation lets a sender start transmitting a large message before it knows its total length, and lets a multiplexing sender interleave control frames. It rarely matters to application code, because libraries reassemble messages before delivering them, but it explains why browsers expose message events with whole messages and why bufferedAmount counts bytes rather than messages. Browser-side details are in The Browser WebSocket API.

Ping, pong and keepalive

A ping (opcode 9) may carry up to 125 bytes of application data. The receiver must answer with a pong (opcode 10) that carries the same data, as soon as practical, unless it has already received a close frame. An unsolicited pong is allowed and is ignored, which makes it a one-way heartbeat. Browsers answer pings automatically and expose no API to send them, which is why Chatterbox sends an application-level presence heartbeat as well as a server-initiated protocol ping every heartbeatMs.

Protocol ping/pong has two uses. It keeps NAT mappings and load-balancer idle timers alive (the AWS Application Load Balancer closes idle connections after 60 seconds by default, so pings must be more frequent than that), and it detects half-open connections: if no pong arrives within a deadline, the server terminates the socket. Intervals, deadlines and reconnection are covered in Connection Lifecycle, Heartbeats and Reconnection.

The closing handshake and close codes

Closing is a two-step protocol, separate from closing TCP. An endpoint that wants to close sends a close frame (opcode 8). Its optional payload is a 2-byte status code (big-endian) followed by a UTF-8 reason, 123 bytes at most because the whole control payload is limited to 125. The peer answers with its own close frame, usually echoing the code. After an endpoint has sent a close frame it sends no more data frames, and after it has received one it discards anything that follows. When both close frames have been exchanged, the server closes the TCP connection first, and the client waits for that. A client that does not see the server close TCP within a reasonable time closes it itself, so a peer that never answers cannot hold the connection open forever.

The raw-socket client below ends its session exactly like this: it sends a masked close frame whose payload is 03 e8 62 79 65 (03 e8 is 1000 and 62 79 65 is the reason bye), and the server answers with the unmasked 88 05 03 e8 62 79 65.

The close-code table

Codes are 16-bit numbers. The ranges are defined by RFC 6455 and the IANA registry:

Code Name Meaning and typical use

1000

Normal Closure

The purpose of the connection is fulfilled. The only code a client library uses by default.

1001

Going Away

A server is shutting down, or a browser tab navigates away.

1002

Protocol Error

A frame violated the protocol: unmasked client frame, unknown opcode, reserved bit set.

1003

Unsupported Data

Received a type of data the endpoint cannot accept, such as binary on a text-only endpoint.

1004

Reserved

Reserved for future use.

1005

No Status Rcvd

Reserved. Never sent on the wire. A library reports it when the peer’s close frame had no code.

1006

Abnormal Closure

Reserved. Never sent on the wire. A library reports it when TCP closed without a close frame, or the handshake failed.

1007

Invalid Payload Data

A text message was not valid UTF-8.

1008

Policy Violation

Generic code for a policy breach when no other code fits.

1009

Message Too Big

A message exceeded what the endpoint can process (maxPayload).

1010

Mandatory Extension

A client expected an extension the server did not negotiate.

1011

Internal Error

The server hit an unexpected condition.

1012

Service Restart

The server is restarting. Registered later at IANA.

1013

Try Again Later

Overloaded or temporarily unavailable. Registered later at IANA.

1014

Bad Gateway

A gateway or proxy got an invalid answer from upstream. Registered later at IANA.

1015

TLS Handshake

Reserved. Never sent on the wire. Reports a failed TLS handshake.

1016-2999

Unassigned

Reserved for the protocol, its extensions and future revisions.

3000-3999

Registered libraries and frameworks

First-come registry at IANA, for example 3000 Unauthorized, 3003 Forbidden, 3008 Timeout.

4000-4999

Private use

Free for applications. Chatterbox defines 4400, 4401, 4403, 4408, 4413, 4426, 4429 and 4503.

Two rules matter in browsers. First, WebSocket.close(code, reason) accepts only 1000 or a code from 3000 to 4999 and throws an InvalidAccessError for anything else, so a page can never send 1001, 1008 or 1011. (A browser may itself send 1001 when a tab unloads, or 1002 and 1007 for protocol errors it detects.) Second, 1005, 1006 and 1015 are for local reporting only: a close frame carrying them is a protocol violation. An application that wants a distinct, actionable code for a client (unauthorized, rate limited, upgrade required) uses the 4000 range, as Chatterbox does: 4401 tells the client to fetch a new ticket, 4429 to back off, and 4503 to reconnect immediately with jitter. The reason text is free-form and must never contain secrets, because it is visible in DevTools and logs.

Subprotocols

Sec-WebSocket-Protocol negotiates the application protocol spoken in the frames. The client lists tokens in preference order, the server picks exactly one and echoes it, and the browser exposes it as socket.protocol. If the server does not support any of them it simply omits the header, and the connection is open with an empty protocol (a server that insists on a subprotocol should refuse the handshake instead). If the server picks a token the client did not offer, the client must fail the connection.

Chatterbox uses the token chatterbox.v1; a future incompatible envelope would be chatterbox.v2, and a client can offer both, newest first, to migrate without breaking old servers. The token is a name, not a URL, and it should be unique: IANA keeps a Subprotocol Name Registry (for example xmpp, the subprotocol of RFC 7395 described in XMPP in the Browser; mqtt; graphql-transport-ws). Registration is optional, but reusing a registered token for a different protocol is a bug waiting for an interoperability problem. Because the header is the only custom value a browser lets a script choose, some applications smuggle a token in it; it is a hack with real drawbacks (the value appears in logs and must be a valid token), compared in Authentication and Authorization.

Extensions

Sec-WebSocket-Extensions negotiates protocol-level changes: the client offers one or more extensions with parameters, and the server answers with the subset it accepts, with the final parameter values. Extensions claim the RSV bits and may add extension data before the payload. They apply in the order listed. The IANA Extension Name Registry holds permessage-deflate (RFC 7692), the one extension in practice. (x-webkit-deflate-frame and deflate-frame, earlier drafts of per-frame compression, were obsolete long ago.)

permessage-deflate (RFC 7692)

The extension compresses each message with raw DEFLATE (RFC 1951) and marks the first frame of a compressed message with RSV1. The sender runs DEFLATE with a Z_SYNC_FLUSH at the end of the message and removes the four trailing bytes 00 00 ff ff; the receiver appends them again before inflating. Four parameters tune it:

Parameter Offered by Meaning

server_no_context_takeover

Client or server

The server resets its compression window after each message. Without it the server keeps one window for the whole connection, so later messages compress against earlier ones.

client_no_context_takeover

Client or server

The same for the client’s compressor.

server_max_window_bits=N

Client or server (N from 8 to 15)

Limits the server’s LZ77 window to 2 to the power N bytes (15 means 32 KiB).

client_max_window_bits and client_max_window_bits=N

Client (bare form) or server (with value)

Limits the client’s window. A client offers the bare parameter to say it can accept a limit, and the server answers with a value or omits it.

The server may accept an offer, accept it with smaller limits, or ignore the extension (the connection then works, uncompressed). The handshake at the top of this page shows a client offering client_max_window_bits and a server answering with server_no_context_takeover, client_no_context_takeover and server_max_window_bits=10, which its perMessageDeflate options requested. Compression is per message, and a sender may leave any message uncompressed by leaving RSV1 clear. The ws option threshold (1024 bytes by default) sets the size below which messages are not compressed when context takeover is disabled.

The next script shows the effect of context takeover on identical Chatterbox envelopes, and then negotiates the extension with the real server (which must be running; see the next section):

// permessage-deflate by hand (RFC 7692 section 7): raw DEFLATE, SYNC_FLUSH, strip the 00 00 ff ff tail.
import zlib from 'node:zlib';
import WebSocket from 'ws';

const TAIL = Buffer.from([0x00, 0x00, 0xff, 0xff]);
const msg = (n) => Buffer.from(JSON.stringify({ v: 1, type: 'message', id: `01JAB3W7Q8Z4N5R6T7V8X9Y0Z${n}`, room: 'general', ts: 0,
  payload: { text: 'Hello, Chatterbox! Anyone up for lunch?', replyTo: null, attachments: [] } }));

function compress(stream, data) {
  return new Promise((resolve) => {
    const chunks = [];
    stream.on('data', (c) => chunks.push(c));
    stream.write(data);
    stream.flush(zlib.constants.Z_SYNC_FLUSH, () => {
      const out = Buffer.concat(chunks);
      resolve(out.subarray(out.length - 4).equals(TAIL) ? out.subarray(0, out.length - 4) : out);
    });
  });
}

// context takeover: one deflate stream for the whole connection
const shared = zlib.createDeflateRaw();
// no_context_takeover: a fresh stream per message
for (const n of ['A', 'B', 'C']) {
  const data = msg(n);
  const a = await compress(shared, data);
  const b = await compress(zlib.createDeflateRaw(), data);
  console.log(`message ${n}: plain=${data.length}  with takeover=${a.length}  no takeover=${b.length}`);
}

// Confirm the negotiation with a real client (server.mjs must be running).
const ws = new WebSocket('ws://127.0.0.1:8080/ws', ['chatterbox.v2', 'chatterbox.v1'], {
  origin: 'https://chat.example.test',
  perMessageDeflate: { clientMaxWindowBits: 10 },
});
ws.on('open', () => {
  console.log('subprotocol :', ws.protocol);
  console.log('extensions  :', ws.extensions);
  ws.close(1000);
});
ws.on('error', (e) => console.log('error', e.message));
message A: plain=175  with takeover=155  no takeover=155
message B: plain=175  with takeover=10  no takeover=155
message C: plain=175  with takeover=9  no takeover=155
subprotocol : chatterbox.v1
extensions  : permessage-deflate

With takeover, the second envelope is 10 bytes instead of 155 because the window still holds the first one. That is the benefit of context takeover for chatty protocols with repetitive JSON. It is also the root of both costs below. (The extensions line prints only the extension name because the ws client reports the extension name, not the negotiated parameters.)

Memory cost per connection

Context takeover keeps compressor and decompressor state alive for as long as the connection lives. The zlib manual gives the deflate memory use as (1 << (windowBits + 2)) + (1 << (memLevel + 9)) + 6 KB, which is about 268 KB at the defaults (windowBits 15, memLevel 8), plus an inflate window of 1 << windowBits bytes (32 KB) in the other direction. A server holding 50,000 compressed connections with takeover could spend more than ten gigabytes on compression state alone at those defaults, before any application data. Besides memory, the ws documentation warns that zlib allocation patterns under load can cause memory fragmentation and slow performance in Node.js, which is why the extension is off by default on the ws server and on by default on its client. The remedies are the ones in the server example below: both no_context_takeover flags, serverMaxWindowBits of 10 (which roughly halves the deflate state), a threshold so small frames are not compressed at all, a lower zlibDeflateOptions.level, and a concurrencyLimit. Chatterbox’s envelopes are small and bursty, so the compression ratio gained from larger windows rarely pays for the memory; Pixel Arena sends binary snapshots with its own delta encoding and disables the extension.

The compression-oracle caveat

Compression leaks length. If a message mixes a secret with text an attacker controls, then guessing the secret one character at a time is possible: a correct guess makes the compressed message shorter, and an observer of the encrypted traffic sees the length. This is the idea behind the CRIME attack on TLS compression and the BREACH attack on HTTP response compression, and it applies to permessage-deflate for the same reason, especially with context takeover, where messages sent earlier (containing the secret) are part of the window against which later attacker-influenced messages are compressed. Rules that follow:

  • Never put a secret (session ticket, CSRF token, API key) in the same compression context as attacker-controlled text. Do not echo a secret back inside a message that reflects user input.

  • Prefer no_context_takeover (also saving memory) and turn the extension off on endpoints that carry secrets next to user-chosen data. A Chatterbox hello carrying a ticket is the kind of message to send uncompressed.

  • Authenticate with a cookie or a ticket in the handshake, not repeatedly in messages.

  • Treat random padding of lengths as a mitigation that costs bandwidth, not as a fix.

WebSocket over HTTP/2 and HTTP/3

The Upgrade mechanism exists only in HTTP/1.1, so WebSocket cannot reuse an HTTP/2 or HTTP/3 connection through it. Two RFCs bootstrap WebSocket over a single stream of a multiplexed connection, so that a page’s WebSockets and requests share one TCP or QUIC connection:

  • RFC 8441 (September 2018), "Bootstrapping WebSockets with HTTP/2", defines the extended CONNECT method. A server advertises support by sending the HTTP/2 setting SETTINGS_ENABLE_CONNECT_PROTOCOL (identifier 0x08) with value 1. A client may then send a CONNECT request that also has a :protocol pseudo-header, here websocket.

  • RFC 9220 (June 2022), "Bootstrapping WebSockets with HTTP/3", applies the same mechanism to HTTP/3, with the setting SETTINGS_ENABLE_CONNECT_PROTOCOL (also 0x08) in the HTTP/3 SETTINGS frame.

The request replaces the HTTP/1.1 handshake. There is no Upgrade, no Sec-WebSocket-Key and no Sec-WebSocket-Accept, because the stream itself identifies the exchange. Sec-WebSocket-Version, Sec-WebSocket-Protocol and Sec-WebSocket-Extensions keep their meaning, and Origin is still sent by browsers:

:method = CONNECT
:protocol = websocket
:scheme = https
:path = /ws
:authority = chat.example.test
sec-websocket-version = 13
sec-websocket-protocol = chatterbox.v1
origin = https://chat.example.test

A successful answer is a 200 status (not 101), after which the stream’s data carries ordinary WebSocket frames (masked in client frames as always). A client must not send an extended CONNECT before it has seen the setting from the server, and a server that receives an unknown :protocol answers 501 Not Implemented. Closing the WebSocket ends the stream (a FIN on the stream, or a stream reset when the connection is aborted) instead of closing the connection.

Why it matters: with HTTP/1.1, each WebSocket needs its own TCP connection, which strains browsers' per-host connection limits and proxies; over HTTP/2 or HTTP/3 many sockets and requests share one connection, and over HTTP/3 a lost packet in one stream no longer stalls the others (though one WebSocket is still an ordered stream). Support belongs to the stack, not to the JavaScript API: the page still calls new WebSocket(url), and the browser chooses the transport. A server or proxy has to support it explicitly (for Node.js, node:http2 can enable the setting through its enableConnectProtocol option and then handle the CONNECT stream itself, while ws itself speaks HTTP/1.1), and many deployments terminate HTTP/2 at the proxy and talk HTTP/1.1 upstream, which keeps application servers unchanged. For new, low-latency designs the alternative is WebTransport and Media over QUIC. Proxy specifics are in Proxies, Load Balancers and Deployment.

The IANA registries

IANA maintains the "WebSocket Protocol Registries": the WebSocket Extension Name Registry, the Subprotocol Name Registry, the Version Number Registry, the Close Code Number Registry, the Opcode Registry and the Framing Header Bits Registry. Close codes 1000-2999 need standards action or specification review, 3000-3999 are first-come-first-served and 4000-4999 are not registered, and RFC 7936 clarified the registration procedures of the subprotocol registry. Before inventing a subprotocol token or a close code in the 3000 range, look at the registry. The page of the registry is the authoritative list of the codes in the table above.

A server that checks Origin and negotiates

A minimal, complete ws server for Chatterbox. It rejects any handshake whose Origin is not on the allow-list, selects chatterbox.v1, configures permessage-deflate conservatively, sets maxPayload, answers pings, and echoes messages. It does the origin check in the HTTP upgrade event, before the WebSocket handshake is completed, so a rejected client gets a plain 403 and never owns a socket.

{
  "name": "chatterbox-wire-lab",
  "private": true,
  "type": "module",
  "dependencies": {
    "ws": "8.22.0"
  }
}
// Chatterbox endpoint: Origin allow-list, subprotocol negotiation, permessage-deflate options.
import http from 'node:http';
import { WebSocketServer } from 'ws';

const ALLOWED_ORIGINS = new Set(['https://chat.example.test', 'http://localhost:8080']);
const PORT = Number(process.env.PORT ?? 8080);

const wss = new WebSocketServer({
  noServer: true,
  maxPayload: 16384,                       // Chatterbox maximum frame: bigger closes with 1009
  // Pick chatterbox.v1 if the client offered it; returning false accepts no subprotocol.
  handleProtocols: (offered) => (offered.has('chatterbox.v1') ? 'chatterbox.v1' : false),
  perMessageDeflate: {
    serverNoContextTakeover: true,         // drop the LZ77 window after every message
    clientNoContextTakeover: true,
    serverMaxWindowBits: 10,               // 1 KiB window instead of 32 KiB
    threshold: 256,                        // do not compress small frames
    zlibDeflateOptions: { level: 3 },
    concurrencyLimit: 4,
  },
});

wss.on('connection', (ws, req) => {
  console.log('connected: protocol=%s extensions=%s origin=%s',
    ws.protocol, ws.extensions, req.headers.origin);
  ws.on('message', (data, isBinary) => {
    console.log('message (%s, %d bytes)', isBinary ? 'binary' : 'text', data.length);
    ws.send(data, { binary: isBinary });
    if (!isBinary && data.toString() === 'ping-me') ws.ping(Buffer.from('hb'));
  });
  ws.on('error', (err) => console.log('socket error: %s', err.code ?? err.message)); // without this, a 1009 crashes the process
  ws.on('close', (code, reason) => console.log('closed: %d %s', code, reason.toString()));
});

const server = http.createServer((req, res) => { res.writeHead(426).end('Upgrade Required'); });

server.on('upgrade', (req, socket, head) => {
  const origin = req.headers.origin;
  if (!origin || !ALLOWED_ORIGINS.has(origin)) {
    // Refuse before the handshake completes: a plain HTTP 403, no WebSocket is created.
    socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
    socket.destroy();
    console.log('rejected origin: %s', origin);
    return;
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});

server.listen(PORT, () => console.log('listening on :%d', PORT));

A client on raw sockets

The last example is a WebSocket client with no WebSocket library: it opens a TCP connection with node:net, performs the handshake, verifies Sec-WebSocket-Accept with the function from the start of this page, sends a masked text frame, decodes the server’s unmasked frames, answers a server ping with a pong that echoes the payload, and closes with code 1000. It offers chatterbox.v2, chatterbox.v1 and permessage-deflate, and (because it never sets RSV1) sends uncompressed frames, which is always allowed.

// A WebSocket client written on top of node:net only: handshake, masked frames, ping/pong, close.
import net from 'node:net';
import { acceptFor, newKey } from './accept.mjs';
import { OP, encodeFrame, decodeFrame, closePayload, hex } from './frames.mjs';

const host = process.argv[2] ?? '127.0.0.1';
const port = Number(process.argv[3] ?? 8080);
const origin = process.argv[4] ?? 'https://chat.example.test';

const key = newKey();
const sock = net.connect(port, host);
let buf = Buffer.alloc(0);
let upgraded = false;

sock.on('connect', () => {
  const req = [
    'GET /ws HTTP/1.1',
    `Host: ${host}:${port}`,
    'Upgrade: websocket',
    'Connection: Upgrade',
    `Sec-WebSocket-Key: ${key}`,
    'Sec-WebSocket-Version: 13',
    `Origin: ${origin}`,
    'Sec-WebSocket-Protocol: chatterbox.v2, chatterbox.v1',
    'Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits',
    '', '',
  ].join('\r\n');
  console.log('>>> request\n' + req);
  sock.write(req);
});

function send(label, opts) {
  const f = encodeFrame({ mask: true, ...opts });
  console.log(`>>> ${label}: ${hex(f.subarray(0, 16))}${f.length > 16 ? ' ...' : ''} (${f.length} bytes)`);
  sock.write(f);
}

sock.on('data', (chunk) => {
  buf = Buffer.concat([buf, chunk]);
  if (!upgraded) {
    const end = buf.indexOf('\r\n\r\n');
    if (end < 0) return;
    const head = buf.subarray(0, end).toString('latin1');
    buf = buf.subarray(end + 4);
    console.log('<<< response\n' + head + '\n');
    if (!head.startsWith('HTTP/1.1 101')) { sock.destroy(); return; }
    const accept = /^sec-websocket-accept:\s*(.+)$/im.exec(head)?.[1]?.trim();
    if (accept !== acceptFor(key)) throw new Error('bad Sec-WebSocket-Accept');
    console.log('Sec-WebSocket-Accept verified');
    upgraded = true;
    // Frames below stay uncompressed (RSV1 = 0); permessage-deflate is optional for the sender.
    const hello = JSON.stringify({ v: 1, type: 'hello', id: '01JAB3W7Q8Z4N5R6T7V8X9Y0ZA', room: null, ts: 0, payload: { client: 'raw/1.0' } });
    send('hello (masked text)', { opcode: OP.TEXT, payload: Buffer.from(hello) });
    send('ping-me (masked text)', { opcode: OP.TEXT, payload: Buffer.from('ping-me') });
  }
  for (;;) {
    const r = decodeFrame(buf);
    if (!r) break;
    buf = buf.subarray(r.consumed);
    const { frame } = r;
    const name = Object.keys(OP).find((k) => OP[k] === frame.opcode);
    const shown = name === 'CLOSE' ? hex(frame.payload) : JSON.stringify(frame.payload.toString());
    console.log(`<<< ${name} fin=${frame.fin} masked=${frame.masked} len=${frame.payload.length} payload=${shown}`);
    if (frame.opcode === OP.PING) {
      send('pong (echo of ping payload)', { opcode: OP.PONG, payload: frame.payload });
      setTimeout(() => send('close 1000 (masked)', { opcode: OP.CLOSE, payload: closePayload(1000, 'bye') }), 50);
    }
  }
});
sock.on('close', () => console.log('socket closed'));

Start the server with node server.mjs, then run node raw-client.mjs. The output, with annotations (the dump shows the request and the response already listed in the handshake section):

Sec-WebSocket-Accept verified
>>> hello (masked text): 81 ea d2 0f 64 5c a9 2d 12 7e e8 3e 48 7e a6 76 ... (112 bytes)
>>> ping-me (masked text): 81 87 e9 08 cd 0f 99 61 a3 68 c4 65 a8 (13 bytes)
<<< TEXT fin=true masked=false len=106 payload="{\"v\":1,\"type\":\"hello\",...}"
<<< TEXT fin=true masked=false len=7 payload="ping-me"
<<< PING fin=true masked=false len=2 payload="hb"
>>> pong (echo of ping payload): 8a 82 c1 b3 fe 61 a9 d1 (8 bytes)
>>> close 1000 (masked): 88 85 88 31 6a 6e 8b d9 08 17 ed (11 bytes)
<<< CLOSE fin=true masked=false len=5 payload=03 e8 62 79 65
socket closed

The frames, byte by byte:

8a 82 c1 b3 fe 61 a9 d1   pong: FIN=1 opcode=0xA, MASK=1 length=2, key c1 b3 fe 61,
                          payload a9 d1 XOR key = 68 62 -> "hb", the same data as the ping
88 85 88 31 6a 6e 8b d9 08 17 ed
                          close: FIN=1 opcode=0x8, MASK=1 length=5, key 88 31 6a 6e,
                          payload 8b d9 08 17 ed XOR key = 03 e8 62 79 65 -> code 1000, reason "bye"
88 05 03 e8 62 79 65      the server's reply: unmasked close, code 1000, reason "bye"
89 02 68 62               the server's ping: opcode 0x9, length 2, "hb" (no mask)

When the same client is run with Origin: https://evil.example, the server never completes the handshake:

HTTP/1.1 403 Forbidden
Connection: close
Content-Length: 0

Security

The protocol’s security rests on a few decisions, and each is the application’s to make.

Cross-Site WebSocket Hijacking. Browsers do not apply the same-origin policy to WebSocket handshakes: a page on any site may open wss://chat.example.test/ws, and the browser attaches the site’s cookies to the request. If the server authenticates the handshake by cookie alone and does not look at Origin, any web page the victim visits can open a socket as the victim, read the replies (a WebSocket, unlike a fetch() to another origin, has no CORS check on reading) and send commands. That is Cross-Site WebSocket Hijacking, the WebSocket variant of cross-site request forgery. The protocol’s own defence is the Origin header, which the browser sets and a page cannot change, so the server must allow-list Origin (as the example above does), refuse anything unexpected with 403, and decide explicitly what to do with requests that have no Origin (non-browser clients). It is the equivalent of the CORS checks described in What is CORS? with the difference that CORS is enforced by the browser while for WebSockets the server is the only enforcer. SameSite=Lax or Strict cookies reduce the exposure, but WebSocket handshakes from the same site (different origin) and older browsers still need the Origin check. A one-time ticket in the URL or in the first message (instead of an ambient cookie) removes ambient authority altogether; the trade-offs of each option are in Authentication and Authorization and Security.

Other rules: use wss:// always (otherwise masking is the only protection and a proxy can read and alter everything); set maxPayload; validate JSON after parsing and never trust the ts or from fields of a client (Chatterbox ignores them); rate-limit per connection; time out connections that never complete hello; and keep close reasons free of internal details.

Inspecting WebSocket traffic

Browser DevTools. In Chrome, open the Network panel, toggle the WS filter, click the connection and open the Messages tab to see the text and binary messages in each direction with their time; the Headers tab shows the handshake request and the 101 response (or the failing status of a refused handshake), which is where a 403 from the origin check or a 400 from a proxy shows up. Firefox has the same filter and a Messages pane. The panels show decoded messages and not raw frames, so masking keys, fragments and RSV bits are not visible, and control frames such as ping and pong are typically not listed as messages. DevTools also throttles WebSocket connections together with other network traffic, which is useful for testing slow links.

Wireshark. Capture the loopback or network interface and filter websocket. The dissector shows each frame with websocket.fin, websocket.opcode, websocket.mask, websocket.masking_key, websocket.payload_length and the unmasked payload (websocket.payload.text), and it reassembles fragmented messages. Useful filters are websocket.opcode == 9 || websocket.opcode == 10 for ping and pong, websocket.opcode == 8 for closes (the status code is websocket.payload.close.status_code) and http.upgrade for the handshake requests. Wireshark cannot read the frames inside wss:// unless you give it the TLS secrets: start the browser with the SSLKEYLOGFILE environment variable set to a file, then point Wireshark’s TLS preferences to the "(Pre)-Master-Secret log filename". For permessage-deflate traffic Wireshark can inflate messages when the context-takeover parameters allow it (the websocket.decompression.failed.expert field flags failures). Use unencrypted ws:// on localhost for the examples of this page and capture the loopback interface.

Common errors and pitfalls

  • A 1006 close with no reason in the browser. The handshake failed (wrong status, missing Upgrade in a proxy, failed Accept, origin refused, TLS error) or the TCP connection dropped without a close frame. Look at the Network panel and the server log; the page cannot see the cause.

  • The proxy strips Upgrade and Connection. They are hop-by-hop headers and must be forwarded explicitly by the reverse proxy, together with a long read timeout. Symptom: the server sees a plain GET, answers 200 or 400, and the client sees a failed handshake.

  • The server process crashes on a bad client. In ws, a connection that emits an error (a frame over maxPayload, invalid UTF-8, a protocol violation) throws if no error listener is attached. The server.mjs above has one for exactly that reason; the first version of the example crashed on the 1009 test.

  • Unmasked or masked-on-the-wrong-side frames. A hand-written client that forgets masking is closed with 1002.

  • Opcode handling. Treating a binary frame as text, or a continuation frame as a new message, corrupts the stream. Always accumulate fragments until FIN.

  • Close code misuse. Sending 1005, 1006 or 1015 in a close frame, or calling close(1001) from a browser, is an error. Use 1000 and 3000-4999.

  • Mixing secrets with compression. See the compression-oracle caveat.

  • Unlimited message size. Leave maxPayload at its default, and one message can use 100 MiB of memory.

  • A reason longer than 123 bytes. Browsers throw a SyntaxError, other libraries truncate or fail.

Version and specification history

  • 2009-2010: HyBi drafts and Hixie. Early WebSocket drafts (Hixie-75 and Hixie-76) used a different handshake and no masking, and were found vulnerable to proxy cache poisoning, which led to masking and the key/accept exchange.

  • December 2011: RFC 6455. Version 13, still the only version in use. RFC 7936 later clarified the registry procedures.

  • December 2015: RFC 7692. permessage-deflate.

  • September 2018: RFC 8441. WebSocket over HTTP/2 with extended CONNECT.

  • June 2022: RFC 9220. The same for HTTP/3.

  • Browsers. The WHATWG WebSockets standard defines the WebSocket API on top of RFC 6455. WebSocketStream, with backpressure, ships only in Chromium 124 and later and is not on a standards track, as the browser API page explains. Node.js has a browser-compatible global WebSocket client (stable since v22.4.0) but no server.