WebSocket Protocol Internals (RFC 6455)
See WebSocket for the API-level overview and HTTP/1.1 for the upgrade mechanism. This page covers the byte-level frame format, masking math, opcode semantics, fragmentation, the
permessage-deflateextension (RFC 7692), and the subprotocol negotiation. The reference is RFC 6455.
1. Where WebSocket Sits
WebSocket is a message-framing protocol on top of a single TCP
connection. It starts life as an HTTP/1.1 request, performs an
Upgrade handshake (which is the only HTTP exchange), and from
there on the same TCP socket carries bidirectional, length-prefixed
frames. A frame is the unit of transport; one or more frames may
make up one logical message (via fragmentation).
client server
────── ─────
GET /chat HTTP/1.1 ────>
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Extensions: permessage-deflate
<──── HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Extensions: permessage-deflate
[ from here on, the socket carries WebSocket frames in both directions ]
binary frame: opcode=2, FIN=1, payload=… ────>
<──── text frame: opcode=1, FIN=1, payload="hi"
ping: opcode=9, FIN=1, payload=4-byte ────>
<──── pong: opcode=A, FIN=1, payload=4-byte
close: opcode=8, FIN=1, payload=03 E8 ────> close: opcode=8, FIN=1, payload=03 E8
(TCP FIN)
The key insight: after the upgrade, there is no more HTTP semantics.
The bytes on the wire are pure WebSocket frames. Intermediaries
(proxies, load balancers) that pass HTTP but cannot speak WebSocket
will break here — that’s why Upgrade: websocket and Connection: Upgrade are hop-by-hop headers in HTTP/1.1.
2. The Handshake: Sec-WebSocket-Key and Sec-WebSocket-Accept
The handshake is a one-shot proof that both sides speak WebSocket and not just plain HTTP. The mechanism is deliberately old-fashioned:
- The client generates 16 random bytes, base64-encodes them, and
sends them as
Sec-WebSocket-Key. - The server concatenates that key with the magic GUID
258EAFA5-E914-47DA-95CA-C5AB0DC85B11(hardcoded in RFC 6455 §1.3), takes the SHA-1 of the concatenation, and base64-encodes the 20-byte digest. That becomesSec-WebSocket-Accept. - The client does the same computation and compares; if it matches, the server is WebSocket-aware, and the client switches to frame mode. If it doesn’t, the server is just an HTTP server that ignored the Upgrade request, and the client must fall back.
In code (Python):
import base64, hashlib, os
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def make_key():
return base64.b64encode(os.urandom(16)).decode()
def make_accept(key: str) -> str:
digest = hashlib.sha1((key + GUID).encode()).digest()
return base64.b64encode(digest).decode()
# client → "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="
# server reply: base64(sha1(key + GUID))
# = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" (matches the RFC 6455 example)
The handshake is not a security mechanism — SHA-1 of a known GUID
plus a public value is trivially computable by any party. The point
is to detect intermediaries that don’t speak WebSocket (cache
proxies that would happily serve back a cached 200 response). It’s a
protocol-version liveness check, not authentication. Authentication
and confidentiality come from running the upgrade over TLS (the
wss:// scheme) and from application-layer tokens in the headers
(e.g. Sec-WebSocket-Protocol or Authorization).
3. Frame Format
Once the handshake completes, every byte on the wire is part of a WebSocket frame. The frame header layout (RFC 6455 §5.2):
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/63) |
|N|V|V|V| |S| | (only present when needed) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set (32) |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+--------------------------------+-------------------------------+
| Payload Data … |
+---------------------------------------------------------------+
Field by field:
- FIN (1 bit) — set if this frame is the final one of a message. Cleared on continuation frames (see §5).
- RSV1, RSV2, RSV3 (3 bits) — reserved. Must be 0 unless an
extension was negotiated. The most common use: RSV1 set = the
frame’s payload is compressed with
permessage-deflate(RFC 7692, see §7). - Opcode (4 bits) — defines what this frame is. See §4.
- MASK (1 bit) — set if the payload is XOR-masked with a 32-bit masking key. MUST be 1 for frames sent from client to server; the server MUST close the connection if it receives an unmasked client frame. MAY be 0 for server-to-client frames; the client SHOULD close the connection if it receives a masked server frame.
- Payload length (7 bits) — overloaded:
- 0–125 → the actual payload length in bytes.
- 126 → the next 2 bytes are an unsigned 16-bit big-endian length.
- 127 → the next 8 bytes are an unsigned 64-bit big-endian length. The 64-bit case is the only reason WebSocket requires a 64-bit length-aware reader. In practice implementations cap it at the receiver’s configured maximum (often 1–16 MB).
- Masking-key (32 bits) — present iff MASK=1. Used to XOR the payload, byte by byte, cyclically (see §5).
- Payload Data — application bytes, masked if MASK=1.
A simple server-side frame parser in Python:
def read_frame(sock) -> tuple[int, bytes]:
b0, b1 = sock.recv(2)
fin = b0 & 0x80
rsv = b0 & 0x70
opcode = b0 & 0x0F
masked = b1 & 0x80
plen = b1 & 0x7F
if plen == 126:
plen = int.from_bytes(sock.recv(2), "big")
elif plen == 127:
plen = int.from_bytes(sock.recv(8), "big")
if masked:
mask = sock.recv(4)
raw = sock.recv(plen)
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(raw))
else:
payload = sock.recv(plen)
return opcode, payload
The first byte b0 is read with & 0x80 (high bit) for FIN, & 0x70
for RSV, & 0x0F for opcode. The second byte b1 is read with & 0x80 for the MASK bit and & 0x7F for the 7-bit payload length.
4. Opcodes
The 4-bit opcode carries the type of frame. Only the low 4 bits are defined:
opcode frame type semantic
------ -------------- ----------------------------------------------
0x0 continuation continues a fragmented message
0x1 text payload is UTF-8 text (MUST be valid UTF-8)
0x2 binary payload is opaque bytes
0x3-0x7 reserved reserved for further non-control frames
0x8 close close the connection; carries 2-byte status
0x9 ping keepalive / liveness probe
0xA pong reply to a ping
0xB-0xF reserved reserved for further control frames
Two important rules:
- Control frames (0x8–0xA) carry at most 125 bytes of payload (the 7-bit length only; extended length is forbidden). They also MUST set FIN=1; they cannot be fragmented. They MAY be inserted in the middle of a fragmented data message — the receiver must service them before continuing the data stream.
- A new data message (text or binary) MUST NOT start until the previous one is complete. Continuation frames (opcode 0) carry the rest of a fragmented message.
The close frame carries a 2-byte big-endian status code, optionally followed by UTF-8 reason text. Standard codes:
1000 normal closure
1001 endpoint going away (page closed, server restarting)
1002 protocol error
1003 unsupported data type
1005 no status received (never on the wire — local-only)
1006 abnormal closure (never on the wire — connection lost)
1007 invalid UTF-8 in text frame
1008 policy violation
1009 message too big
1011 internal server error
1015 TLS handshake failure (local-only)
When closing, the side that initiates sends a close frame; the other side MUST echo a close frame back before tearing down TCP. If the peer doesn’t echo within a reasonable timeout, the connection is abandoned (status 1006 locally).
5. Masking
Client-to-server frames MUST be XOR-masked. The masking key is a freshly generated 32-bit random value, sent in the clear in the header. The payload is XORed with the 4-byte key cyclically:
transformed[i] = original[i] XOR mask[i mod 4]
Decode is identical: XOR is its own inverse. Why do this, when the key is sent in plaintext? The answer is not cryptographic — it’s a defense against protocol-confusion attacks on intermediary caches (see the original rationale, the “cold-cache attack” described in the WebSocket Threat Model RFC 6455 §10.7). Some pre-2010 HTTP servers and caching proxies would interpret bytes that looked like HTTP requests inside the body of a request as pipeline commands. A malicious page could craft a WebSocket payload that, to such an intermediary, looked like a POST to an internal endpoint — a cache poisoning vector.
XOR masking the payload with a fresh per-frame key invalidates any
fixed-pattern attack. The attacker can’t predict the masking key at
the time they construct the payload, so they can’t pre-construct a
masked payload that decodes to a chosen plaintext. The 4-byte key
is sufficient because the threat model is intermediary confusion,
not confidentiality — TLS provides confidentiality when needed
(wss://).
Server-to-server frames need not be masked: the server is a trusted endpoint, and the threat model doesn’t apply. The client should still enforce: if it receives a masked server frame, it should fail the connection.
A trivial implementation of masking using Python’s int.from_bytes
for speed:
def apply_mask(data: bytes, mask: bytes) -> bytes:
out = bytearray(len(data))
m = int.from_bytes(mask, "big")
# process 4 bytes at a time
for i in range(0, len(data) - 3, 4):
w = int.from_bytes(data[i:i+4], "big")
out[i:i+4] = (w ^ m).to_bytes(4, "big")
# tail
for i in range(len(data) - (len(data) % 4), len(data)):
out[i] = data[i] ^ mask[i % 4]
return bytes(out)
Real implementations either process in 4-byte chunks like the above,
or use a vectorized approach (e.g. SIMD XOR or bytes(0 for _ in mask) then bytes-translated). The masking step is the dominant CPU
cost on the client side for large frames.
6. Fragmentation
A single logical message can be split across multiple frames. The
first frame has the actual opcode (text or binary) and FIN=0; each
subsequent frame has opcode 0 (continuation) and FIN=0 on all but
the last, which has FIN=1. So a 200 KB binary message sent in 64 KB
chunks looks like:
frame 1: FIN=0, opcode=2, len=65536
frame 2: FIN=0, opcode=0, len=65536
frame 3: FIN=0, opcode=0, len=65536
frame 4: FIN=1, opcode=0, len=32768
total: 200000 bytes
Why fragment?
- The sender doesn’t know the total message size when it starts sending — typical of streamed TTS, ASR, or chat. Fragmentation lets you emit frames as data is produced, without buffering the whole message.
- To interleave with control frames. A long-running message can be paused to send a PING/PONG, then resumed.
- To respect the receiver’s backpressure. Sending a 100 MB message in one frame would block the TCP socket for the entire duration; chunking lets the receiver’s ACKs throttle the sender.
Control frames MAY arrive mid-message. The receiver must service them immediately and then continue the data stream. This means a fragmented data message is not actually atomic at the transport layer — a PONG frame can be wedged in between any two fragments.
The receiver reassembles the message by concatenating the payloads of all frames of the same message in order. The text-message decoder must also re-validate UTF-8 only after the final frame — the boundary between two fragments may fall in the middle of a multi-byte UTF-8 sequence. So a 4-byte UTF-8 emoji split across two fragments must not be individually validated.
7. Subprotocols (Sec-WebSocket-Protocol)
A client may advertise a list of application-layer subprotocols it speaks:
Sec-WebSocket-Protocol: chat, superchat, mqtt-3.1.1
The server picks one (or none) and echoes it back:
Sec-WebSocket-Protocol: chat
The chosen subprotocol is then the contract for what bytes mean on
the wire. WebSocket is just a transport; chat, superchat, and
mqtt-3.1.1 define entirely different framing on top. This is
important: WebSocket’s own opcodes (text, binary) carry no application
semantics. They’re just transport hints.
Common subprotocols include:
- MQTT over WebSocket —
mqtt-3.1.1,mqtt-5. Many IoT backends expose MQTT over WebSocket so they can be reached from a browser. - STOMP —
v10.stomp,v11.stomp,v12.stomp. A simple text-oriented messaging protocol popular in Spring Boot. - WAMP —
wamp.2.jsonetc. RPC + PubSub, popular in JavaScript frameworks. - Custom —
chat.v1,live-transcription.v2— versioned in the subprotocol name to allow schema evolution.
If the server doesn’t accept any of the offered subprotocols, it MUST
send back the 101 with no Sec-WebSocket-Protocol header (or fail the
handshake). The client then decides whether to proceed without a
contract.
8. Extensions: permessage-deflate (RFC 7692)
The single widely-deployed WebSocket extension is
permessage-deflate, which zlib-compresses the payload of data
messages. It is negotiated via the Sec-WebSocket-Extensions
header:
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
The server replies with the parameters it accepts:
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=10; server_max_window_bits=10
Once negotiated, the RSV1 bit (the high RSV bit) is overloaded: set
RSV1=1 on the first frame of a message to indicate that the
message’s payload is a zlib-compressed stream (using the DEFLATE
algorithm with no zlib header, the raw DEFLATE blocks). The receiver
decompresses by feeding the concatenated payload bytes into a
zlib stream initialized with wbits = -window_bits (negative, raw,
no header).
The wrinkle is the context takeover. By default, the zlib stream
persists across messages — the compressor keeps its dictionary, so
small repeated messages like { "type": "tick", "v": 42 } can be
compressed to a few bytes after the first one. To disable this,
either side can negotiate client_no_context_takeover and/or
server_no_context_takeover; each message then resets the zlib
state, trading compression ratio for memory.
A careful server implementation will:
- Cap the decompression buffer. A 1 kB compressed payload can decompress to megabytes — the classic “WebSocket zip-bomb” attack. Apply a hard limit on decompressed size and abort the connection if exceeded.
- Reset the zlib stream on protocol error.
- Not enable
permessage-deflatefor binary frames whose semantics require no transformation (e.g. binary streams that are already gzip-compressed — double compression only hurts).
The Sec-WebSocket-Extensions header can also carry other extensions
(e.g. permessage-brotli in drafts), but permessage-deflate is the
only standardized one.
9. Common Pitfalls
- Reading a partial frame. A single
recv()on the underlying TCP socket may return any number of bytes from 1 to N — there is no guarantee that a frame’s bytes arrive in one read. Always loop until you have a full frame’s worth, or use a buffered reader. - Forgetting to mask client frames. A server that accepts unmasked client frames violates the spec and exposes itself to cache-confusion attacks via confused intermediaries. Always mask on the client side and reject unmasked client frames on the server side.
- Sending the close frame without an echoed close. The initiating side should send close, wait for the peer’s close frame, then tear down TCP. Tearing down immediately can cause the peer to receive a TCP RST and report status 1006 (abnormal closure) instead of the intended status.
- Mis-validating UTF-8. Text frames must be valid UTF-8 when reassembled — not per-frame. If you validate each fragment independently and a multi-byte character spans the boundary, you’ll reject a valid message.
- Compression bombs. With
permessage-deflate, an attacker can send a small compressed payload that decompresses to gigabytes. Cap decompression in flight, not just final size. - Trusting
Originfor security. TheOriginheader is for the server to decide whether to accept the upgrade; it does not authenticate the user. Cross-site request forgery is still possible — pairOriginchecks with CSRF tokens or session cookies. - Sending binary data as text. Text frames MUST be valid UTF-8. A binary frame (opcode 2) is for opaque bytes. Pick the right opcode — mixing them confuses intermediaries and browsers.
References
- RFC 6455 — The WebSocket Protocol. https://www.rfc-editor.org/rfc/rfc6455.html
- RFC 7692 — Compression Extensions for WebSocket
(
permessage-deflate). https://www.rfc-editor.org/rfc/rfc7692.html - RFC 8441 — Bootstrapping WebSockets with HTTP/2. Defines the
:protocolextended CONNECT that lets WebSocket run over HTTP/2 streams. https://www.rfc-editor.org/rfc/rfc8441.html - RFC 7935 — The WebSocket Protocol’s Handshake Security (the SHA-1/GUID liveness check rationale, RFC 6455 §1.3 + §10.7).
- MDN Web Docs — Writing WebSocket servers (byte-level tutorial with parser examples). https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers
- MDN Web Docs — WebSocket (browser API). https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
- WHATWG — HTML Living Standard, §9 The WebSocket API (the browser interface). https://html.spec.whatwg.org/multipage/web-sockets.html
- IETF — websocket WG archive (history of the spec). https://datatracker.ietf.org/wg/hybi/documents/
- Wikipedia — WebSocket (overview and history). https://en.wikipedia.org/wiki/WebSocket
Cross-References
- WebSocket (API overview) — high-level usage.
- HTTP/1.1 — the protocol that the Upgrade handshake runs on top of.
- HTTP/2 — RFC 8441 lets WebSocket run over HTTP/2 streams.
- TLS / HTTPS — the
wss://transport. - QUIC internals — comparison: why QUIC’s multiplexed streams don’t require WebSocket’s framing tricks.