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.

Advertisement

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).

One RFC 6455 frame: a 2-byte fixed header, optional extended length, optional masking key, payloadByte 0FIN1 bitRSV11 bitRSV21 bitRSV31 bitopcode4 bitsByte 1MASK1 bitpayload len7 bits: 0-125, or 126 / 127extended payload length0 bytes, 2 bytes (len=126) or 8 bytes (len=127)masking key4 bytes, present only if MASK=1payload dataextension data, then application data (XOR-masked if MASK=1)opcodes0 cont, 1 text, 2 binary8 close, 9 ping, A pongheader sizeserver to client: 2-10 Bclient to server: 6-14 Bcontrol framesFIN=1, payload at most 125 B
The layout of one frame. The 7-bit length decides whether 0, 2 or 8 extended length bytes follow. The masking key is present only on frames sent by the client.
FieldBitsMeaning
FIN11 on the last frame of a message.
RSV1, RSV2, RSV31 each0 unless a negotiated extension defines them.
opcode4Frame type; unlisted values are reserved.
MASK11 if a 4-byte masking key follows; set on client frames only.
payload len70-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.

Advertisement

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.

CodeMeaningNotes
1000Normal closureDone.
1001Going awayServer shutdown or page navigation.
1002Protocol errorBad framing, masking or fragment sequence.
1007Invalid payload dataTypically invalid UTF-8 in a text message.
1008Policy violationGeneric code when no other applies.
1009Message too bigYour size limits.
1011Internal errorThe server hit an unexpected condition.
1005, 1006, 1015ReservedLocal reporting only; never sent.
3000-3999, 4000-4999Libraries and applications3000s 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

  1. Decode the RFC's "Hello" frame by hand once, then decode a frame from your own application's traffic using a hex dump.
  2. 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.
  3. Confirm your server rejects unmasked client frames, reserved opcodes, non-minimal lengths and fragmented control frames.
  4. Write a test that sends a ping between two fragments of a text message and checks that both are delivered intact.
  5. If you use permessage-deflate, apply the message limit after decompression.
  6. 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.
  7. Read backpressure in bidirectional streams next, because frames that parse correctly can still arrive faster than you can process them.
Key takeaway: A WebSocket frame is a 2-byte header, an optional 2- or 8-byte extended length, a 4-byte masking key on client frames, and the payload. The rules that matter are minimal length encoding, masking in one direction only, control frames of at most 125 bytes that may arrive between fragments, whole-message UTF-8 validation, and RSV bits that stay at zero unless an extension claims them. A good parser buffers incrementally, rejects oversized frames before reading them, keeps fragment state separate from control frames, and fails with the right close code.