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
| Requirement | Common starting point | Key boundary |
|---|---|---|
| Low-frequency requests, cacheable resources | Ordinary HTTP | Avoid introducing long-lived connections |
| Server pushing events to browser | Server-Sent Events | One-way, text-based event stream; browser includes built-in reconnection |
| High-frequency bidirectional messaging between browser and server | WebSocket | Must define custom message schema, reconnection strategy, and backpressure policies |
| Typed service-to-service RPC | gRPC | Deadline, 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:
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.comWhen the server agrees to switch protocols, it responds with:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=The calculation for Sec-WebSocket-Accept is:
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:
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: Continuation0x0, text0x1, binary0x2, close0x8, ping0x9, pong0xA;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:
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:
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
- Why can't a single
recv()correspond to a single WebSocket frame? - How should the parser handle a Ping message received during the middle of a text message split into three frames?
- Why does
Sec-WebSocket-Acceptnot authenticate the server? - How to distinguish between protocol-level Ping/Pong messages, business acknowledgment messages, and reconnect resume signals?
- 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.