After the HTTP upgrade, a WebSocket connection is a stream of frames on a plain TCP (or TLS) connection. Every byte your server reads goes through a frame parser. Too lenient, and hostile input corrupts messages, exhausts memory or confuses intermediaries; too strict in the wrong places, and it rejects valid traffic such as a ping in the middle of a fragmented message.
This article takes the frame format defined in RFC 6455 apart bit by bit. It covers the header, length encodings, masking, fragmentation, control and close frames, and extension bits, then decodes real bytes by hand and builds a Python parser and encoder with production limits. The surrounding deployment architecture is covered in WebSocket architecture.
The frame header, bit by bit
Every frame starts with two fixed bytes. The first byte holds four flags and the opcode. The second holds the mask flag and a 7-bit length field. Depending on those two bytes, up to 12 more header bytes follow before the payload. All multi-byte integers are in network byte order (big-endian).
| Field | Bits | Meaning |
|---|---|---|
| FIN | 1 | 1 on the last frame of a message. |
| RSV1, RSV2, RSV3 | 1 each | 0 unless a negotiated extension defines them. |
| opcode | 4 | Frame type; unlisted values are reserved. |
| MASK | 1 | 1 if a 4-byte masking key follows; set on client frames only. |
| payload len | 7 | 0-125 literal; 126 or 127 select a 16- or 64-bit length. |
The opcode values split into two ranges. Data frames use 0x0 (continuation), 0x1 (text) and 0x2 (binary), with 0x3 to 0x7 reserved. Control frames use 0x8 (close), 0x9 (ping) and 0xA (pong), with 0xB to 0xF reserved. The top opcode bit therefore marks a control frame.
Payload length: three encodings and one rule
The 7-bit length field can describe at most 125 bytes directly. Two values are escape codes. If the field is 126, the next two bytes are an unsigned 16-bit length, enough for payloads up to 65,535 bytes. If it is 127, the next eight bytes are an unsigned 64-bit length, and the most significant bit of that value must be 0.
RFC 6455 also requires the minimal number of bytes to be used. A 100-byte payload must use the 7-bit form, not 126 followed by 0x0064. A strict parser rejects non-minimal encodings: correct senders never produce them, and tolerating them lets two parsers in the path disagree about frame boundaries.
The length is per frame, not per message. A parser therefore enforces two limits: a maximum frame size, checked as soon as the length is known and before buffering, and a maximum message size, checked as fragments accumulate.
Masking: the mechanics and the reason
When MASK is 1, a 4-byte masking key follows the length. Each payload byte is XORed with a byte of the key: byte i of the payload is XORed with byte i mod 4 of the key. Because XOR is its own inverse, the same function masks and unmasks. The key must be fresh for every frame and come from a strong source of randomness.
Masking is not encryption. The key is sent in the clear right next to the data. It exists to protect intermediaries that do not understand WebSocket. Before RFC 6455 was finished, researchers showed that a script in a browser could send bytes that a transparent proxy would read as an HTTP request and response, and so cache attacker-chosen content under another site's URL. Because a browser masks every client frame with a key that the script cannot predict, the script can no longer choose the bytes that appear on the wire.
That is why the rule is asymmetric and strict. A server must close the connection, normally with status 1002 (protocol error), if it receives an unmasked frame from a client. A client must close the connection if it receives a masked frame from a server.
Opcodes, fragmentation and interleaved control frames
A message is either a single frame with FIN=1 and a text or binary opcode, or a sequence of frames. In a fragmented message, the first frame carries the real opcode with FIN=0, middle frames carry opcode 0x0 with FIN=0, and the last frame carries opcode 0x0 with FIN=1. Senders fragment to stream data of unknown total size.
Control frames have three extra rules: the payload is at most 125 bytes, FIN must be 1, and they may appear between the fragments of a data message. That lets a ping or close get through during a large message, so a parser must keep fragment state separate from control frames and deliver control frames immediately.
Data messages may not be interleaved with one another. Once a fragmented message has started, the next data frame must be a continuation.
Text messages must be valid UTF-8 across the whole message, and the receiver must fail the connection with status 1007 if they are not. A character may straddle fragments, so validate the reassembled message or use an incremental decoder.
A ping may carry up to 125 bytes of application data, and the pong that answers it must echo that data. An unsolicited pong is allowed as a one-way heartbeat. The browser WebSocket API does not expose ping or pong at all; browsers answer pings automatically. Heartbeat design is covered in heartbeats for bidirectional connections.
Worked example: decoding frames by hand
RFC 6455 includes sample frames, and decoding them by hand is the quickest way to make the layout stick. Here is a masked client frame carrying the text "Hello":
81 85 37 fa 21 3d 7f 9f 4d 51 58
0x81 = 1000 0001 FIN=1, RSV=000, opcode=0x1 (text)
0x85 = 1000 0101 MASK=1, payload len=5
37 fa 21 3d masking key
7f 9f 4d 51 58 masked payload
0x7f ^ 0x37 = 0x48 'H'
0x9f ^ 0xfa = 0x65 'e'
0x4d ^ 0x21 = 0x6c 'l'
0x51 ^ 0x3d = 0x6c 'l'
0x58 ^ 0x37 = 0x6f 'o' (index 4 wraps to key byte 0)The same message from a server is 81 05 48 65 6c 6c 6f: no mask bit and no key. Sent as two fragments, it becomes 01 03 48 65 6c (text, FIN=0, "Hel") followed by 80 02 6c 6f (continuation, FIN=1, "lo"). An unmasked ping carrying "Hello" starts with 89 05. A 256-byte binary frame from a server starts 82 7e 01 00, and a 65,536-byte one starts 82 7f 00 00 00 00 00 01 00 00.
An incremental parser with limits
TCP delivers a byte stream, not frames, so a read may return half a header or three frames at once. A parser must buffer, decode only when enough bytes are present, and enforce every rule above. This server-side parser returns complete messages and control frames, and raises an error carrying the close code to send.
import struct
MAX_FRAME = 1 << 20 # 1 MiB per frame
MAX_MESSAGE = 4 << 20 # 4 MiB per reassembled message
class ProtocolError(Exception):
def __init__(self, code, reason):
super().__init__(reason)
self.code = code
def unmask(data, key):
n = len(data)
k = int.from_bytes((key * (n // 4 + 1))[:n], "big")
return (int.from_bytes(data, "big") ^ k).to_bytes(n, "big")
class FrameParser:
def __init__(self, expect_masked=True, allowed_rsv=0):
self.buf = bytearray()
self.expect_masked = expect_masked
self.allowed_rsv = allowed_rsv
self.op, self.parts, self.size = None, [], 0
def feed(self, data):
self.buf += data
out = []
while (frame := self._frame()) is not None:
out.extend(self._assemble(*frame))
return out
def _frame(self):
b = self.buf
if len(b) < 2:
return None
fin, rsv, op = b[0] & 0x80, (b[0] >> 4) & 7, b[0] & 0x0F
masked, n, pos = b[1] & 0x80, b[1] & 0x7F, 2
if rsv & ~self.allowed_rsv:
raise ProtocolError(1002, "reserved bit set")
if op in (3, 4, 5, 6, 7) or op > 0xA:
raise ProtocolError(1002, "reserved opcode")
if bool(masked) != self.expect_masked:
raise ProtocolError(1002, "wrong masking for direction")
if n == 126:
if len(b) < 4:
return None
n, pos = struct.unpack_from("!H", b, 2)[0], 4
if n < 126:
raise ProtocolError(1002, "non-minimal length")
elif n == 127:
if len(b) < 10:
return None
n, pos = struct.unpack_from("!Q", b, 2)[0], 10
if n >> 63 or n < 65536:
raise ProtocolError(1002, "bad 64-bit length")
if op >= 8 and (not fin or n > 125):
raise ProtocolError(1002, "bad control frame")
if n > MAX_FRAME:
raise ProtocolError(1009, "frame too big")
if masked:
pos += 4
if len(b) < pos + n:
return None
payload = bytes(b[pos:pos + n])
if masked:
payload = unmask(payload, bytes(b[pos - 4:pos]))
del b[:pos + n]
return fin, op, payload
def _assemble(self, fin, op, payload):
if op >= 8:
return [(op, payload)]
if (op == 0) == (self.op is None):
raise ProtocolError(1002, "bad fragment sequence")
self.op = self.op or op
self.size += len(payload)
if self.size > MAX_MESSAGE:
raise ProtocolError(1009, "message too big")
self.parts.append(payload)
if not fin:
return []
op, data = self.op, b"".join(self.parts)
self.op, self.parts, self.size = None, [], 0
if op == 1:
try:
data = data.decode("utf-8")
except UnicodeDecodeError:
raise ProtocolError(1007, "invalid UTF-8") from None
return [(op, data)]The check (op == 0) == (self.op is None) covers both fragment errors at once: a continuation with no message in progress, and a new data frame while one is in progress. The frame-size check runs before the parser waits for the payload, so an oversized announcement is rejected after at most 10 bytes. The unmask routine XORs two big integers in one operation, far faster in Python than a byte loop; native code XORs 8 bytes at a time or uses SIMD.
If permessage-deflate is negotiated, RSV1 marks the first frame of a compressed message. The receiver inflates the reassembled payload before the UTF-8 check, and the size limit must apply after decompression too. The negotiation and its memory costs are covered in WebSocket compression.
Encoding frames
The encoder is shorter because you control the input. It chooses the minimal length encoding and masks only when acting as a client.
import os
def encode_frame(op, payload, fin=True, mask=False):
head = bytearray([(0x80 if fin else 0) | op])
n, m = len(payload), (0x80 if mask else 0)
if n < 126:
head.append(m | n)
elif n < 65536:
head.append(m | 126)
head += struct.pack("!H", n)
else:
head.append(m | 127)
head += struct.pack("!Q", n)
if mask:
key = os.urandom(4)
head += key
payload = unmask(payload, key)
return bytes(head) + payload
assert encode_frame(0x1, b"Hello") == bytes.fromhex("810548656c6c6f")For broadcast, encode once and write the same bytes to every connection; unmasked server frames let all subscribers share one buffer.
Close frames and status codes
A close frame's payload is either empty or starts with a 2-byte big-endian status code, optionally followed by a UTF-8 reason. Because control payloads are limited to 125 bytes, the reason can be at most 123 bytes. Truncate longer reasons at a character boundary.
| Code | Meaning | Notes |
|---|---|---|
| 1000 | Normal closure | Done. |
| 1001 | Going away | Server shutdown or page navigation. |
| 1002 | Protocol error | Bad framing, masking or fragment sequence. |
| 1007 | Invalid payload data | Typically invalid UTF-8 in a text message. |
| 1008 | Policy violation | Generic code when no other applies. |
| 1009 | Message too big | Your size limits. |
| 1011 | Internal error | The server hit an unexpected condition. |
| 1005, 1006, 1015 | Reserved | Local reporting only; never sent. |
| 3000-3999, 4000-4999 | Libraries and applications | 3000s IANA-registered; 4000s private. |
The closing handshake is symmetric. One side sends a close frame and stops sending data. The other replies with a close frame, normally echoing the code, and then the server closes the TCP connection. Time out a peer that never replies.
Failure modes
- Unbounded buffering. No frame or message limit lets one client exhaust server memory with a single announced length or an endless stream of continuation frames.
- Control frames breaking reassembly. A ping in the middle of a fragmented message overwrites the parser's state, and the message is silently corrupted.
- Lenient masking. Accepting unmasked client frames works in testing with non-browser clients, then removes the protection masking gives intermediaries.
- Per-fragment UTF-8 checks. Validating each fragment separately rejects valid messages whose multi-byte characters straddle a fragment boundary.
- Compression bombs. With permessage-deflate, limits applied only to compressed sizes let a small frame expand into a huge message.
- Close handling bugs. Sending reserved codes such as 1006, or reasons longer than 123 bytes, makes strict peers fail the connection with a protocol error.
To test a parser, use a conformance suite such as the Autobahn test suite, and add tests that feed every valid frame one byte at a time.
What to do next
- Decode the RFC's "Hello" frame by hand once, then decode a frame from your own application's traffic using a hex dump.
- Check whether your server library lets you set a maximum frame size and a maximum message size, and set both to values derived from your largest legitimate message.
- Confirm your server rejects unmasked client frames, reserved opcodes, non-minimal lengths and fragmented control frames.
- Write a test that sends a ping between two fragments of a text message and checks that both are delivered intact.
- If you use permessage-deflate, apply the message limit after decompression.
- Run a conformance suite against your server, and feed your parser random bytes one at a time to make sure it never crashes or buffers without limit.
- Read backpressure in bidirectional streams next, because frames that parse correctly can still arrive faster than you can process them.