Eleven Bytes: Reading a WebSocket Off the Wire
A WebSocket message is eleven bytes on the wire, and almost everything written about those bytes is checkable against a real socket in about a second.

Eleven Bytes: Reading a WebSocket Off the Wire
00000000 81 85 37 fa 21 3d 7f 9f 4d 51 58 |..7.!=..MQX|That is a complete WebSocket message. Five bytes of payload, six bytes of header, no
Content-Type, no Cookie, no 200 OK. It says "Hello". The same string over an HTTP
poll from a real browser costs 879 bytes on the wire, which I measured rather than
guessed, and I will show you both captures.
Everything below ran on localhost against Node v22.22.3 and ws@8.21.3. Every hexdump is
captured output, not an illustration.
The handshake is one SHA-1, and it is not about caching
A WebSocket starts as an ordinary HTTP/1.1 GET. Here is the request my raw client sent
and the response a Node server sent back, printed straight from the socket with \r\n
shown as a pilcrow:
--- request ---
GET /chat HTTP/1.1⏎
Host: localhost:8080⏎
Upgrade: websocket⏎
Connection: Upgrade⏎
Sec-WebSocket-Key: 2njpx9pIbgjoDV5+BcJl5w==⏎
Sec-WebSocket-Version: 13⏎
⏎
--- response ---
HTTP/1.1 101 Switching Protocols⏎
Upgrade: websocket⏎
Connection: Upgrade⏎
Sec-WebSocket-Accept: cq1mM3DpAOOPJv91/ewhVpFVWP0=⏎
⏎
accept matches: true
has Content-Length: falseStatus 101, Connection: Upgrade, and no Content-Length — because there is no body, and
after the blank line the bytes stop being HTTP entirely. The whole server side of that
exchange is a hash:
// accept-key.mjs
import { createHash } from 'node:crypto';
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
// RFC 6455 section 1.3
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
export const acceptFor = (key) =>
createHash('sha1').update(key + WS_GUID).digest('base64');
// The worked example in RFC 6455 section 1.3
const rfcKey = 'dGhlIHNhbXBsZSBub25jZQ==';
assert(Buffer.from(rfcKey, 'base64').length === 16, 'the key is a 16-byte nonce, base64-encoded');
assert(acceptFor(rfcKey) === 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=', 'accept value must match RFC 6455 1.3');Both asserts pass. The intermediate SHA-1 is b37a4f2cc0624f1690f64606cf385945b2bec4ea,
byte-for-byte the value RFC 6455 prints, and dGhlIHNhbXBsZSBub25jZQ== decodes to the ASCII
string the sample nonce.
You will read that Sec-WebSocket-Key exists to stop caching proxies from serving your
private chat messages to the next visitor. That is the justification for masking, not
for the key. RFC 6455 section 1.3 states the actual reason: "the server has to prove to the
client that it received the client's WebSocket handshake, so that the server doesn't accept
connections that are not WebSocket connections. This prevents an attacker from tricking a
WebSocket server by sending it carefully crafted packets using XMLHttpRequest or a form
submission." It is a cross-protocol defence. A <form> post cannot produce that header, so
it cannot produce that response.
The frame header, and the bit everyone gets wrong
Two bytes minimum. Byte 0 is FIN | RSV1 | RSV2 | RSV3 | opcode(4). Byte 1 is
MASK | length(7). Opcodes worth memorising: 0x0 continuation, 0x1 text, 0x2 binary,
0x8 close, 0x9 ping, 0xa pong. RSV1 is not always zero — permessage-deflate sets it,
and I will show that frame later.
The length field is the part that bites. Seven bits, or 7+16, or 7+64:
0–125is the length itself.126means read the next 2 bytes as a big-endianuint16, so up to 65,535 bytes.127means read the next 8 bytes as a big-endianuint64.
That last case gets quoted as "18 exabytes". It is not. RFC 6455 section 5.2 says the 8 bytes
are "a 64-bit unsigned integer (the most significant bit MUST be 0)", capping a frame at
2^63 - 1, about 9.2 exabytes. Half the number you were told, and a parser that skips the rule
accepts lengths no conformant peer will send.
Here is a codec that encodes and decodes the RFC's own test vector, and — the part that
matters — returns null when it does not yet have a whole frame:
// frame.mjs
const assertFrame = (cond, msg) => { if (!cond) throw new Error(msg); };
export function hexdump(buf) {
const out = [];
for (let i = 0; i < buf.length; i += 16) {
const row = buf.subarray(i, i + 16);
const hex = [...row].map((b) => b.toString(16).padStart(2, '0')).join(' ').padEnd(47);
const txt = [...row].map((b) => (b > 31 && b < 127 ? String.fromCharCode(b) : '.')).join('');
out.push(`${i.toString(16).padStart(8, '0')} ${hex} |${txt}|`);
}
return out.join('\n');
}
export function encodeFrame({ opcode = 0x1, payload = Buffer.alloc(0), fin = true, maskKey = null }) {
const b0 = (fin ? 0x80 : 0) | opcode;
const n = payload.length;
let header;
if (n < 126) {
header = Buffer.from([b0, (maskKey ? 0x80 : 0) | n]);
} else if (n < 65536) {
header = Buffer.alloc(4);
header[0] = b0; header[1] = (maskKey ? 0x80 : 0) | 126; header.writeUInt16BE(n, 2);
} else {
header = Buffer.alloc(10);
header[0] = b0; header[1] = (maskKey ? 0x80 : 0) | 127; header.writeBigUInt64BE(BigInt(n), 2);
}
if (!maskKey) return Buffer.concat([header, payload]);
const masked = Buffer.from(payload);
for (let i = 0; i < masked.length; i++) masked[i] ^= maskKey[i % 4];
return Buffer.concat([header, maskKey, masked]);
}
// Returns null when `buf` does not yet hold a whole frame. That return value is
// the entire point: TCP hands you arbitrary slices, not messages.
export function decodeFrame(buf) {
if (buf.length < 2) return null;
const fin = (buf[0] & 0x80) !== 0;
const rsv1 = (buf[0] & 0x40) !== 0;
const opcode = buf[0] & 0x0f;
const isMasked = (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;
const big = buf.readBigUInt64BE(2);
if (big > 0x7fffffffffffffffn) throw new Error('RFC 6455 5.2: the high bit of a 64-bit length MUST be 0');
len = Number(big); off = 10;
}
let maskKey = null;
if (isMasked) {
if (buf.length < off + 4) return null;
maskKey = buf.subarray(off, off + 4); off += 4;
}
if (buf.length < off + len) return null;
const payload = Buffer.from(buf.subarray(off, off + len));
if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4];
return { fin, rsv1, opcode, isMasked, maskKey, payload, frameLength: off + len, headerLength: off };
}
// The single-frame masked "Hello" from RFC 6455 section 5.7
const vector = encodeFrame({ payload: Buffer.from('Hello'), maskKey: Buffer.from([0x37, 0xfa, 0x21, 0x3d]) });
assertFrame(vector.toString('hex') === '818537fa213d7f9f4d5158', 'must match RFC 6455 5.7 masked example');
assertFrame(decodeFrame(vector).payload.toString() === 'Hello', 'round-trip must unmask back to Hello');
assertFrame(decodeFrame(vector.subarray(0, 7)) === null, 'a partial frame must decode to null');Decoded, those eleven bytes are FIN=1 RSV1=0 opcode=0x1 MASK=1 len=5 key=37fa213d, payload
"Hello". The direction of that MASK bit is not a style choice. RFC 6455 section 5.1: "A
server MUST NOT mask any frames that it sends to the client. A client MUST close a connection
if it detects a masked frame." Client to server, always masked. Server to client, never. The
reason is in section 10.3, and it is a real historical attack: an experiment "demonstrate[d] a
class of attacks on proxies that led to the poisoning of caching proxies deployed in the wild",
so "the defense adopted is to mask all data from the client to the server, so that the remote
script (attacker) does not have control over how the data being sent appears on the wire".
Masking is not encryption. It is a guarantee that a script cannot choose the bytes.
Where the from-scratch server in every tutorial breaks
The canonical teaching server reads a two-byte header (widening to four when byte 1 says 126),
slices the mask off, XORs, echoes. I ran one against a raw client and against ws@8. Five
failures, each reproducible in under a second.
One TCP read is not one frame. I wrote two frames in a single write(). The server echoed
seven bytes — 81 05 66 69 72 73 74, just "first". The second message was silently discarded.
One frame is not one TCP read. I split a 24-byte frame after 8 bytes. The server parsed the first slice as a whole frame and the remainder as a new one:
server echoed 26 bytes: "spõVZܹXM«H½\\R¶OL"That is your JSON parser throwing at 3am on a message nobody can reproduce.
A ping is not data. I sent opcode 0x9. The server echoed it back as opcode 0x1, text.
RFC 6455 section 5.5.2 is explicit: "Upon receipt of a Ping frame, an endpoint MUST send a Pong
frame in response, unless it already received a Close frame."
Close is a handshake, not a hangup. I sent 0x8 with code 1000. The server sent zero bytes
back and called socket.end(). No Close frame, ever.
The echo path corrupts itself past 125 bytes. writeUInt8(payload.length, 1) for a 200-byte
message writes 0xc8, which sets the MASK bit and declares a length of 72. A real client refuses
immediately:
A: ws client ERROR -> Invalid WebSocket frame: MASK must be clearAt 300 bytes it stops being a protocol error and becomes an outage:
RangeError [ERR_OUT_OF_RANGE]: The value of "value" is out of range.
It must be >= 0 and <= 255. Received 300
at Buffer.writeUInt8 (node:internal/buffer:750:10)That is an unhandled throw inside a data handler. It takes the process down, and with it
every other connection on that box. One 300-byte chat message.
A server that survives contact
Same shape, five holes closed: a pending buffer across reads, control frames handled before
data, fragments reassembled, unmasked frames rejected, a size cap.
// server.mjs
import http from 'node:http';
import { acceptFor } from './accept-key.mjs';
import { encodeFrame, decodeFrame } from './frame.mjs';
const MAX_MESSAGE = 1 << 20; // 1 MiB. Pick a number; "unbounded" is not a number.
const server = http.createServer((req, res) => res.end('speak websocket to me\n'));
server.on('upgrade', (req, socket) => {
const key = req.headers['sec-websocket-key'];
if (req.headers.upgrade?.toLowerCase() !== 'websocket' || !key) {
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
return;
}
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${acceptFor(key)}\r\n\r\n`
);
let pending = Buffer.alloc(0); // bytes TCP has handed us but we cannot parse yet
let fragments = []; // an in-progress fragmented message
let fragmentOpcode = null;
let closing = false;
const send = (opcode, payload) => {
if (!socket.writableEnded) socket.write(encodeFrame({ opcode, payload })); // never masked
};
const fail = (code, reason) => {
closing = true;
const body = Buffer.alloc(2 + Buffer.byteLength(reason));
body.writeUInt16BE(code, 0);
body.write(reason, 2);
send(0x8, body);
socket.end();
};
socket.on('data', (chunk) => {
pending = Buffer.concat([pending, chunk]);
for (;;) {
let frame;
try { frame = decodeFrame(pending); } catch { return fail(1002, 'bad length'); }
if (!frame) return; // incomplete: wait for more TCP
pending = pending.subarray(frame.frameLength);
if (!frame.isMasked) return fail(1002, 'client frames must be masked'); // RFC 6455 5.1
if (frame.opcode >= 0x8) {
if (!frame.fin || frame.payload.length > 125) return fail(1002, 'bad control frame');
if (frame.opcode === 0x9) { send(0xa, frame.payload); continue; } // ping -> pong
if (frame.opcode === 0xa) continue; // pong bookkeeping
if (frame.opcode === 0x8) {
if (!closing) send(0x8, frame.payload.subarray(0, 2)); // echo the code
closing = true;
socket.end();
return;
}
}
if (frame.opcode === 0x0 && fragmentOpcode === null) return fail(1002, 'continuation without start');
if (frame.opcode !== 0x0) fragmentOpcode = frame.opcode;
fragments.push(frame.payload);
const total = fragments.reduce((n, b) => n + b.length, 0);
if (total > MAX_MESSAGE) return fail(1009, 'message too big');
if (!frame.fin) continue;
const message = Buffer.concat(fragments);
fragments = []; const op = fragmentOpcode; fragmentOpcode = null;
send(op, message); // echo, with a correct extended length
}
});
socket.on('error', () => socket.destroy());
});
server.listen(8080, () => console.log('ws://localhost:8080'));Driving it with a raw client that deliberately packs a ping and both halves of a fragmented
message into one write():
server frame: opcode=0xa MASK=0 len=2 header=2B
00000000 8a 02 68 62 |..hb|
server frame: opcode=0x1 MASK=0 len=11 header=2B
00000000 81 0b 66 72 61 67 2d 6d 65 6e 74 65 64 |..frag-mented|
server frame: opcode=0x1 MASK=0 len=200 header=4B
00000000 81 7e 00 c8 41 41 41 41 41 41 41 41 41 41 41 41 |.~..AAAAAAAAAAAA|
...12 more rows of 41...
server frame: opcode=0x8 MASK=0 len=2 header=2B
00000000 88 02 03 e8 |....|
close code echoed by server: 1000Read 81 7e 00 c8: FIN set, text, MASK clear, 0x7e = 126 meaning "extended", then 0x00c8
= 200. That is the 7+16 encoding on a real wire, and 88 02 03 e8 is a Close frame carrying
0x03e8 = 1000. Pointing ws@8 at the same server, payloads of 5, 200, 70,000 and 300,000
bytes all echo back at the right length, and client.ping('alive?') returns a pong with the
payload "alive?", as section 5.5.3 requires.
879 bytes to move 17
Numbers, measured through a byte-counting TCP proxy sitting between client and server. Payload
is a 17-byte JSON tick, {"p":42.17,"t":9}, 100 exchanges, one TCP connection each way.
| transport | total bytes | per message |
|---|---|---|
| HTTP/1.1 keep-alive polling | 29,000 | 290 B |
| WebSocket | 4,496 | 45 B |
| WebSocket + permessage-deflate | 2,044 | 20 B |
The WebSocket figures include the whole handshake amortised over 100 messages; steady state is
6 header bytes up and 2 down, so 42 bytes per round trip. The 290-byte HTTP figure is generous
to HTTP, because Node's client sends no cookies and no User-Agent. The same poll from a
logged-in Chrome tab — session cookie, sec-ch-ua, Accept-Encoding, Referer — measured
688 bytes of request against a 191-byte response. 879 bytes to move 17.
Latency is a different question from bytes, and it has a smaller answer. Each figure below is the median of 1,000 sequential round trips over loopback, client and server in the same Node process on a 4-core arm64 Linux container:
WebSocket send -> echo : 0.054 ms
HTTP/1.1 keep-alive req -> res : 0.093 ms
HTTP/1.1 new TCP conn each : 0.238 msThose three are themselves medians of eleven runs of that benchmark. Individual runs landed between 0.054 and 0.057, between 0.091 and 0.096, and between 0.236 and 0.240 ms, so the ordering is settled even if the third decimal is not. Per round trip the WebSocket beat keep-alive HTTP by 1.6x to 1.8x across those runs — call it 1.7x — and beat a fresh TCP connection per message by 4.4x.
Notice how much smaller 1.7x is than the 6.4x in the bytes table a paragraph up. They are not the same claim, and "WebSockets are faster" usually turns out to mean the bytes one. Loopback also has no TLS handshake, no congestion window and no proxy hop — costs the third row would pay on every single message over a real network, and the first two rows pay once.
The two failures the protocol will not report
A dead peer looks exactly like a quiet one. I put a TCP proxy between client and server and
made it stop forwarding without closing either socket, which is what a NAT box or load balancer
does when it evicts an idle flow. Nginx's proxy_read_timeout defaults to 60s; an AWS
Application Load Balancer's idle_timeout.timeout_seconds defaults to 60 seconds. Neither
sends you anything.
== no heartbeat ==
t+0.5s middlebox goes silent (both TCP sockets still open, no FIN, no RST)
t+8.6s verdict: NONE - server still believes the client is connected; sockets OPEN = 1
== ping/pong heartbeat, 1s interval ==
t+9.4s middlebox goes silent
t+10.9s verdict: no pong within the window -> terminatedThe heartbeat run uses a one-second interval so the test finishes while you are still watching
it. The hub.mjs below ships 15 seconds, which is the number you want in production and which
would have made that second block take half a minute: detection costs you between one and two
intervals, always.
Without a heartbeat the server held that connection open indefinitely and kept writing app data
into a void, with no error. Note the asymmetry: the browser WebSocket API cannot help you here.
Its whole IDL in the WebSockets Standard is send(), close(), url, readyState,
bufferedAmount, extensions, protocol, binaryType, the four CONNECTING/OPEN/CLOSING/
CLOSED constants and the four event handlers. There is no ping(). The browser answers pings below the JavaScript layer;
it cannot originate one. If you want the client to detect a dead server, that is an
application-level message you write yourself.
send() lies about delivery. It queues. I paused one client's socket and kept publishing
64 KiB messages for three seconds:
sent 41071 distinct 64 KiB messages in 3s to a paused reader
bufferedAmount = 2564.8 MiB readyState OPEN = true
RSS 66.6 -> 2652.5 MiB (+2585.9 MiB in 3 s)2.5 GB of resident memory, from one slow consumer, in three seconds. send() never threw and
readyState never left OPEN. Both fixes are about ten lines:
// hub.mjs
import { WebSocketServer, WebSocket } from 'ws';
export const hub = new WebSocketServer({ port: 8081 });
const HIGH_WATER = 4 * 1024 * 1024; // bytes we will hold for one slow client
hub.on('connection', (client) => {
client.isAlive = true;
client.on('pong', () => { client.isAlive = true; });
const heartbeat = setInterval(() => {
if (!client.isAlive) return client.terminate(); // no pong since the last tick: it is gone
client.isAlive = false;
client.ping();
}, 15_000);
client.on('close', () => clearInterval(heartbeat));
});
export function publish(client, data) {
if (client.readyState !== WebSocket.OPEN) return false;
if (client.bufferedAmount > HIGH_WATER) { // the kernel and ws are both full
client.close(1013, 'too slow'); // 1013 Try Again Later, IANA close-code registry
return false;
}
client.send(data);
return true;
}Measured: publish() accepted 104 messages, 6.5 MiB, before the guard fired at a
bufferedAmount of 4.0 MiB — the gap is the kernel send buffer.
Closing on purpose
Close codes live in RFC 6455 section 7.4.1, and two of them are traps. 1005 and 1006 are what
your onclose handler reports; they are not things you send. The RFC: "1006 is a reserved
value and MUST NOT be set as a status code in a Close control frame by an endpoint." Any article
that offers 1006 as an example close-frame payload has never sent one. ws@8 refuses outright:
close(1006): REJECTED -> TypeError: First argument must be a valid error code number
close(1002): accepted by ws@8The browser is stricter still. Per the WebSockets Standard, close(code) throws InvalidAccessError
unless code is 1000 or in the range 3000 to 4999 — so a page cannot send 1001, 1008 or 1011
either. Your application codes go in 4000–4999. And a bare close() with no arguments sends a
Close frame with an empty payload, which I captured as 88 80 12 8e 55 63: opcode 0x8, MASK
set, length 0. No status code on the wire at all.
A client to server frame arrives with byte 1 equal to 0x7e. How many more bytes must you read before you know the payload length?
Your Node server holds 5,000 WebSocket connections. One client's phone goes through a tunnel and its TCP flow is silently dropped by a NAT box. What does the server observe?
The upgrade request is not subject to CORS. There is no preflight and no
Access-Control-Allow-Origin to satisfy, so a page on any domain can open a socket to your
server; the Origin header is sent, and checking it is entirely your job. What is no longer
true is the scarier half of that warning. A cross-site handshake is not a top-level navigation,
and cookies default to Lax enforcement, which per draft-ietf-httpbis-rfc6265bis is sent "with
same-site requests, and with cross-site top-level navigations". So a drive-by socket carries
your session cookie only if that cookie is explicitly SameSite=None; Secure. Check Origin
anyway, and authenticate with a token you verify in the upgrade handler rather than with
ambient cookies.
What changed since most WebSocket tutorials were written
- "WebSockets do not work over HTTP/2." RFC 8441, Bootstrapping WebSockets with HTTP/2,
published September 2018, "defines a mechanism for running the WebSocket Protocol (RFC 6455)
over a single stream of an HTTP/2 connection". The
GETplusUpgradehandshake is replaced by an extendedCONNECTcarrying a:protocolpseudo-header set towebsocket. RFC 9220 does the same for HTTP/3. - "There is no compression." RFC 7692 defines
permessage-deflate, negotiated in the handshake viaSec-WebSocket-Extensionsand flagged per frame byRSV1. A 579-byte presence payload came back as a 116-byte frame in my capture, startingc1 72: FIN set, RSV1 set, opcode text. - "Socket.io is WebSockets." It is not, but the usual explanation of why is also wrong.
EIO=4is a query parameter the client puts in its request line, not a payload the server returns. I pointed a socket.io client at a plain server and capturedGET /socket.io/?EIO=4&transport=websocket HTTP/1.1. Against a barewsserver the RFC 6455 handshake actually succeeds — the connection then dies withconnect_error: timeout, because the Engine.IO layer is waiting for an OPEN packet that never comes. - "Discord scales WebSockets with a Redis pub/sub backplane." Not according to Discord. How Discord Scaled Elixir to 5,000,000 Concurrent Users describes BEAM processes, not an external bus: "Users connect to a WebSocket and spin up a session process (a GenServer), which then communicates with remote Erlang nodes that contain guild (internal for a 'Discord Server') processes (also GenServers). When anything is published in a guild, it is fanned out to every session connected to it." The word Redis does not appear on that page. A Redis or Kafka fan-out is a perfectly good design — just do not put Discord's name on it.
The last four bytes
Almost nothing in this article breaks loudly. A frame
boundary that does not line up with a TCP read hands your JSON parser a slice of somebody's
masked payload at 3am. A ping answered as text does not throw; it just quietly means your
liveness check is measuring nothing. A middlebox that stops forwarding leaves readyState at
OPEN for as long as you care to wait. A slow reader turns send() into an unbounded queue
that will exhaust the box before anything in the API changes value. Of the five, only
writeUInt8(300, 1) announces itself — and it announces itself by taking down every other
connection on the process.
So the checks worth writing are the ones that turn silence into a signal. On the framing side
that means buffering across reads instead of trusting a chunk boundary, and dispatching on the
control opcodes before you ever look at a text frame. On the liveness side it means a ping on a
timer with a terminate on the missed pong, and a bufferedAmount read before every send. The
size cap and the Origin check are not the protocol's job and never will be — a cap you chose
is a decision, and no cap is not a decision at all.
The last thing the hardened server wrote to that socket was this:
00000000 88 02 03 e8 |....|Opcode 0x8, two bytes of payload, 0x03e8 = 1000. A close frame carrying a reason, sent in
reply to the client's close frame, before either end went near TCP. Four bytes. The tutorial
server from the top of this post sends none of them — it calls socket.end() and lets the FIN
do the talking, and the peer never learns why.
Comments (0)
No comments yet. Be the first to share your thoughts!
Related Articles

