Skip to content

VOL 04 / CH 06 / LESSON 01

6.1 WebSocket Handshake, Frames, and Connection Lifecycle ​

HTTP's core interaction is request-and-response; connections can be reused, but that doesn't automatically make them bidirectional channels where messages can be sent at any time. If a server needs to push events continuously, or if two services require strongly typed remote procedure call contracts, how should the conversation model change? WebSocket and gRPC provide two different answers.

WebSocket defines long-lived, bidirectional message exchange. Choose it according to message flow and the cost of maintaining connections.

1. First, select the protocol based on interaction pattern ​

RequirementCommon starting pointKey boundary
Low-frequency requests, cacheable resourcesOrdinary HTTPAvoid introducing long-lived connections
Server pushing events to browserServer-Sent EventsOne-way, text-based event stream; browser includes built-in reconnection
High-frequency bidirectional messaging between browser and serverWebSocketMust define custom message schema, reconnection strategy, and backpressure policies
Typed service-to-service RPCgRPCDeadline, status, schema evolution, and load balancing are more critical

HTTP uses request/response semantics, but HTTP/1.1 is not a half-duplex transport. TCP is full-duplex, and HTTP/1.1 permits pipelining with responses returned in request order. Browser and intermediary support is limited in practice. WebSocket changes communication after the upgrade into an independent, bidirectional message protocol.

2. HTTP/1.1 Upgrade Handshake ​

The client first sends an HTTP/1.1 request:

http
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.com

When the server agrees to switch protocols, it responds with:

http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The calculation for Sec-WebSocket-Accept is:

text
base64(SHA-1(Sec-WebSocket-Key +
             "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

The SHA-1 hash here does not serve as a password hashing mechanism or digital signature. Instead, the key and GUID are used to allow the client to verify that the server correctly understands the WebSocket handshake, and to reduce the risk of cached responses or cross-protocol confusion. It does not authenticate the server, nor does it replace TLS. For protected connections, use wss://.

Concatenate the Base64 text of the key without decoding it first. Generate a fresh random 16-byte key for each new handshake; this uses the specification’s fixed test vector:

python
import base64
import hashlib

key = "dGhlIHNhbXBsZSBub25jZQ=="
guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
accept = base64.b64encode(hashlib.sha1((key + guid).encode("ascii")).digest())
assert accept.decode("ascii") == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="

The client must parse the status code and required fields, including Upgrade, Connection, and Sec-WebSocket-Accept. Do not require an exact reason phrase or search a byte stream for an accept value. The response may be followed immediately by the first WebSocket frame; preserve those leftover bytes.

HTTP/2 does not use the above 101 upgrade mechanism; RFC 8441 defines extended CONNECT. HTTP/3's bootstrapping is specified in RFC 9220. Both of these require support from client, server, and intermediate infrastructure, so one cannot infer that WebSocket traffic is using the corresponding protocol just because a site supports HTTP/2 or HTTP/3.

3. Frame is the transmission unit for messages ​

The base frame header includes:

  • FIN: Indicates whether this frame is the last frame of the current message;
  • RSV1..3: Must be 0 when no extension negotiation is performed;
  • opcode: Continuation 0x0, text 0x1, binary 0x2, close 0x8, ping 0x9, pong 0xA;
  • MASK: Client-to-server frames must be masked; server-to-client frames must be unmasked;
  • payload length: 7 bits, or extended to 16 or 64 bits as needed.

An application message can be split into an initial data frame and continuation frames. Control frames cannot be fragmented, have at most 125 payload bytes, and may appear between data fragments. The reassembled text message must be valid UTF-8; a single fragment may split a multibyte character.

TCP does not guarantee that one recv(n) returns n bytes. This Python 3 decoder uses recv_exact for short reads and checks the length before reading the payload:

python
from dataclasses import dataclass
import socket
import struct

class ProtocolError(Exception):
    pass

@dataclass(frozen=True)
class Frame:
    fin: bool
    opcode: int
    payload: bytes

def recv_exact(sock: socket.socket, size: int) -> bytes:
    if size < 0:
        raise ValueError("negative read size")
    payload = bytearray()
    remaining = size
    while remaining:
        chunk = sock.recv(remaining)
        if not chunk:
            raise EOFError("connection closed inside a frame")
        payload.extend(chunk)
        remaining -= len(chunk)
    return bytes(payload)

def read_frame(
    sock: socket.socket,
    *,
    expect_masked: bool,
    max_payload: int = 1 << 20,
) -> Frame:
    if max_payload < 0:
        raise ValueError("negative payload limit")
    first, second = recv_exact(sock, 2)
    fin = bool(first & 0x80)
    rsv = first & 0x70
    opcode = first & 0x0F
    masked = bool(second & 0x80)
    length = second & 0x7F

    if rsv:
        raise ProtocolError("RSV bit set without an extension")
    if masked != expect_masked:
        raise ProtocolError("invalid masking direction")
    if opcode not in {0x0, 0x1, 0x2, 0x8, 0x9, 0xA}:
        raise ProtocolError("unknown or reserved opcode")

    if length == 126:
        length = struct.unpack("!H", recv_exact(sock, 2))[0]
        if length < 126:
            raise ProtocolError("non-minimal payload length")
    elif length == 127:
        raw = recv_exact(sock, 8)
        if raw[0] & 0x80:
            raise ProtocolError("payload length exceeds 63 bits")
        length = struct.unpack("!Q", raw)[0]
        if length < 65536:
            raise ProtocolError("non-minimal payload length")

    is_control = opcode >= 0x8
    if is_control and (not fin or length > 125):
        raise ProtocolError("invalid control frame")
    if opcode == 0x8 and length == 1:
        raise ProtocolError("close payload cannot be one byte")
    if length > max_payload:
        raise ProtocolError("frame payload exceeds configured limit")

    mask = recv_exact(sock, 4) if masked else b""
    payload = recv_exact(sock, length)
    if masked:
        payload = bytes(value ^ mask[i % 4] for i, value in enumerate(payload))
    return Frame(fin, opcode, payload)

This function decodes one frame. It does not reassemble messages, validate text UTF-8 or close codes and reasons, support extensions, or manage a closing state machine. The caller must check continuation order and total reassembled size. A real connection also needs an outer deadline for a complete frame or message; repeated short reads can keep resetting a per-call socket timeout. This example does not manage that time budget.

The four-byte masking key travels in cleartext with the frame. Masking changes the byte pattern sent by a client; it does not provide confidentiality. Clients must choose a fresh, unpredictable key for every frame, including control frames.

This reader returns at most one byte per call to reproduce short reads deterministically. It decodes RFC 6455’s masked Hello, then UTF-8 fragments of “中” separated by Ping. This checks one known sequence, not a general message assembler:

python
class ShortReader:
    def __init__(self, data: bytes):
        self.data = data

    def recv(self, size: int) -> bytes:
        part = self.data[:min(size, 1)]
        self.data = self.data[len(part):]
        return part

masked = bytes.fromhex("81 85 37 fa 21 3d 7f 9f 4d 51 58")
assert read_frame(ShortReader(masked), expect_masked=True).payload == b"Hello"

reader = ShortReader(bytes.fromhex("01 01 e4 89 01 70 80 02 b8 ad"))
first, ping, last = [read_frame(reader, expect_masked=False) for _ in range(3)]
assert (first.opcode, first.fin, ping.opcode, last.opcode, last.fin) == (
    1, False, 9, 0, True,
)
assert ping.payload == b"p"
print((first.payload + last.payload).decode("utf-8"))
# 中

4. Ping/Pong Does Not Equal Application Liveness ​

Before receiving Close, an endpoint receiving Ping must reply with Pong and should do so promptly, using the same payload. If multiple Pings await replies, it may reply only to the most recently processed one. Unsolicited Pong is also allowed and needs no reply.

A protocol heartbeat can detect some connection failures, but cannot prove that business events were processed. WebSocket preserves message order in each direction within one connection. Processing acknowledgments, ordering across reconnects, and recovery still require message IDs, acknowledgments, and replay policies.

5. Close handshake and reconnection ​

After sending Close, an endpoint must not send more data frames. An endpoint receiving Close must reply if it has not already sent Close; exchanging both completes the closing handshake. For TCP after an HTTP/1.1 upgrade, the server then closes TCP and the client normally waits for that closure. A timeout or protocol error can still require an abnormal termination instead of waiting indefinitely.

Close may have no payload. Otherwise, the first two bytes contain a close code, followed by at most 123 bytes of UTF-8 reason text. Reserved indicators 1005, 1006, and 1015 must not be sent in Close frames. Reconnection is handled by the application or library, usually with exponential backoff and jitter, plus a policy to discard, buffer, or resume missed messages by sequence number. Logs should distinguish normal closure, TCP EOF, and protocol errors.

6. Backpressure and Large Messages ​

A successful send() usually means data entered a local buffer, not that the peer application processed it. A producer that continually outpaces its consumer grows the queue, memory use, and latency.

The classic browser WebSocket API has no awaitable drain(). bufferedAmount counts application bytes not yet handed to the network, excluding protocol overhead, kernel buffers, and peer processing. Before sending, combine it with the new message size to control production; also bound the receive-side processing queue.

A production service must clearly define:

  • Maximum frame size and maximum reassembled message size;
  • Per-connection and global outbound queue limits;
  • Whether to block, drop, coalesce, or close when the queue is full;
  • Slow consumer timeout thresholds;
  • Decompressed message size and resource limits after compression is enabled.

7. Security Boundary Between Browser and Server ​

Browser handshakes include Origin. WebSocket does not use Fetch CORS preflights and response fields to authorize cross-origin access. Servers must check Origin against their policy, especially when the handshake carries cookies, to prevent cross-site WebSocket hijacking. Non-browser clients can forge Origin, so this check cannot replace client authentication.

The browser WebSocket API cannot set arbitrary handshake headers. Options include cookies, constrained short-lived token schemes, or authentication messages after connection establishment, with attention to log leakage, cross-site risks, expiry, and reconnects. A subprotocol field negotiates protocols; using a subprotocol is not itself authentication. A token carried there must also respect field syntax and logging rules. Avoid putting long-lived credentials directly in URL query parameters.

8. Acceptance Questions ​

  1. Why can't a single recv() correspond to a single WebSocket frame?
  2. How should the parser handle a Ping message received during the middle of a text message split into three frames?
  3. Why does Sec-WebSocket-Accept not authenticate the server?
  4. How to distinguish between protocol-level Ping/Pong messages, business acknowledgment messages, and reconnect resume signals?
  5. What explicit queue limits and overflow policies should be defined for a slow consumer?

The next lesson moves into 6.2 gRPC Contracts, Streaming RPC, and Failure Boundaries.

References ​

Built with VitePress | Software Systems Atlas