WebSocket Protocol
RFC 6455. Starts as an HTTP/1.1 request with an Upgrade: websocket header; after the handshake, the same TCP connection carries WebSocket frames in both directions until either side closes. The protocol itself is minimal — frame structure, control frames, close codes.
The handshake
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat.v1, chat.v2
Origin: https://example.com
Server response on accept:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat.v1
After the 101, the connection switches protocols. No more HTTP — both sides now send and receive WebSocket frames.
Key headers:
| Header | Means |
|---|---|
Upgrade: websocket |
client wants to switch protocols |
Connection: Upgrade |
this is an upgrade-style request |
Sec-WebSocket-Key |
client-chosen random base64 nonce |
Sec-WebSocket-Accept |
server hashes the key + magic GUID with SHA1; proves it’s a real WebSocket server, not a confused HTTP endpoint |
Sec-WebSocket-Version |
always 13 for RFC 6455 |
Sec-WebSocket-Protocol |
optional subprotocol negotiation (e.g., mqtt, wamp.2.json, custom) |
Sec-WebSocket-Extensions |
extensions like permessage-deflate (compression) |
Origin |
the requesting origin — server should validate to prevent cross-origin attacks |
The Sec-WebSocket-Accept math:
hash = SHA1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
accept = base64(hash)
The magic GUID is hardcoded in the spec. The client verifies the server hashed the key correctly. Prevents accidentally talking to an unrelated HTTP server.
Frame structure
Once upgraded, data flows as frames. Simplified layout:
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/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+-------------------------------+-------------------------------+
| Masking-key (4 bytes, client→server only) |
+---------------------------------------------------------------+
: Payload Data :
+---------------------------------------------------------------+
Per-frame bits:
| Bit | Meaning |
|---|---|
FIN |
last frame in a message (1) or more coming (0 — fragmentation) |
RSV1-3 |
extension bits (e.g., permessage-deflate uses RSV1) |
opcode |
frame type (see below) |
MASK |
masked? Client→server frames MUST be masked; server→client MUST NOT |
payload len |
7-bit length, with extended forms for larger |
Opcodes (frame types)
| Opcode | Name | Use |
|---|---|---|
| 0x0 | Continuation | next part of a fragmented message |
| 0x1 | Text | UTF-8 text payload |
| 0x2 | Binary | raw bytes |
| 0x3-0x7 | reserved | non-control |
| 0x8 | Close | initiate connection close |
| 0x9 | Ping | keepalive / health check |
| 0xA | Pong | response to ping |
| 0xB-0xF | reserved | control |
Control frames (0x8, 0x9, 0xA) MUST be ≤ 125 bytes and not fragmented. Used for connection management, not data.
Most libraries hide opcodes — you call ws.send_text(...) or ws.send_bytes(...) and the library picks the opcode.
Why client→server frames are masked
The masking-key XORs the payload before sending. The server XORs back. This is not for security (the key is in the frame); it’s a defense against ancient HTTP proxies that might cache or interpret WebSocket bytes as HTTP responses.
Implication: client → server is more CPU-intensive than server → client. Negligible for normal payloads.
Fragmentation
A single message can span multiple frames. FIN=0 means more frames follow; FIN=1 is the last.
Frame 1: opcode=0x1 (Text), FIN=0, payload="Hello "
Frame 2: opcode=0x0 (Continuation), FIN=0, payload="from "
Frame 3: opcode=0x0 (Continuation), FIN=1, payload="server"
Used for streaming large messages without buffering. Most apps don’t fragment intentionally; the library handles it.
Control frames (ping, pong, close) can be interleaved between data frame fragments. So a long message doesn’t block pings.
Close handshake
Either side initiates by sending a close frame (opcode 0x8) with a 2-byte status code + optional reason:
Side A: Close(1000, "going away") →
Side B: ← Close(1000, "")
both close the TCP connection
Standard close codes (RFC 6455 §7.4):
| Code | Meaning |
|---|---|
| 1000 | Normal closure |
| 1001 | Going away (browser navigating away, server shutting down) |
| 1002 | Protocol error |
| 1003 | Unsupported data (e.g., text received but server only handles binary) |
| 1005 | (reserved — no status received) |
| 1006 | (reserved — abnormal closure, no close frame) |
| 1007 | Invalid frame payload data (e.g., non-UTF-8 in text frame) |
| 1008 | Policy violation |
| 1009 | Message too big |
| 1010 | Mandatory extension (client asked for extensions server didn’t provide) |
| 1011 | Internal server error |
| 1015 | (reserved — TLS handshake failure) |
| 3000-3999 | Reserved for libraries / frameworks |
| 4000-4999 | Application-specific |
For custom app errors, use 4000-4999. E.g., 4001 = “auth failed.”
URL schemes
ws://example.com/chat # WebSocket over HTTP (port 80)
wss://example.com/chat # WebSocket over HTTPS (port 443)
wss:// is HTTPS-style: TLS first, then HTTP upgrade, then WebSocket. Always use wss:// in production. Browsers refuse ws:// from HTTPS pages.
Extensions
Sec-WebSocket-Extensions lets client and server negotiate optional features. The main one in practice: permessage-deflate (per-message compression).
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Tradeoff: CPU cost for compression vs bandwidth savings. Good for chat / JSON; bad for already-compressed payloads (binary images, encrypted data).
Subprotocols
Sec-WebSocket-Protocol lets both sides agree on an application-level protocol on top of WebSocket:
Sec-WebSocket-Protocol: mqtt, stomp, wamp.2.json
The server picks one and responds with the chosen subprotocol. Useful when the same endpoint speaks multiple protocols.
In Python:
async def handler(websocket):
subprotocol = websocket.subprotocol # e.g., "mqtt"
HTTP/2 and HTTP/3
WebSockets defined over HTTP/1.1 originally. RFC 8441 added “WebSockets over HTTP/2” using CONNECT instead of Upgrade. RFC 9220 extended this to HTTP/3.
In practice: most servers and proxies still use HTTP/1.1 for WebSockets. HTTP/2 WebSocket support is uneven; HTTP/3 even rarer. For broad compatibility, HTTP/1.1.
What WebSocket is NOT
- Not stateless. Connection persists; both sides keep state. See ../../07_rest_apis/09_stateful_vs_stateless.md.
- Not message-bounded reliably. TCP delivers bytes; frame boundaries are the protocol’s job. Libraries hide this, but at the network level no message-boundary guarantee comes for free.
- Not HTTP. Only the handshake is HTTP. After 101, the protocol bytes don’t follow HTTP semantics — caches, intermediaries, load balancers that don’t understand WebSocket break things.
- Not a queue. No persistent message storage. If the client is offline, messages aren’t delivered later. Layer your own queue if needed.
- Not load-balancer-friendly by default. Long-lived connections complicate routing; see 07_scaling.md.
Common pitfalls
- No
wss://in production — sniffable plaintext WebSockets; firewalls / corporate proxies often block plainws://. - Forgetting Origin checks — server accepts WebSocket connections from any origin = CSWSH (Cross-Site WebSocket Hijacking). See 06_security.md.
- Treating large messages as atomic — a 100 MB message blocks other writes on the same socket. Stream large data with chunked app-level framing.
- No close frame on shutdown — peer sees abnormal close (1006), can’t tell normal from network failure.
- Mixing text and binary frames carelessly — clients expecting one type, server sends the other; opcodes differ.
Common interview confusions
- “WebSocket is HTTP.” — only the handshake. After
101, no HTTP semantics apply. - “WebSocket replaces HTTP for everything.” — different tool. HTTP for request/response (caching, REST). WebSocket for push and bidirectional.
- “The connection stays open without traffic.” — most intermediaries (firewalls, NATs, load balancers) drop idle connections after 30-300s. Heartbeats keep them alive. See 05_connection_management.md.
Interview angle
- “How does the WebSocket handshake work?” — client sends HTTP/1.1 GET with
Upgrade: websocket,Connection: Upgrade,Sec-WebSocket-Key(random nonce),Sec-WebSocket-Version: 13. Server responds101 Switching ProtocolswithSec-WebSocket-Accept(SHA1 of key + magic GUID). After 101, the connection carries WebSocket frames. - “What’s a WebSocket frame?” — binary structure containing: FIN bit, opcode (text/binary/ping/pong/close), MASK bit, payload length, masking key (client→server only), payload. The basic unit of WebSocket communication.
- “What are the WebSocket message types?” — text frames (UTF-8), binary frames, and control frames: ping, pong, close. Control frames are ≤125 bytes and used for connection management.
- “What are common WebSocket close codes?” — 1000 normal, 1001 going away, 1002 protocol error, 1006 abnormal (no close frame received — typically network failure), 1008 policy violation, 1011 internal server error. Custom app codes 4000-4999.
- “What’s the difference between
ws://andwss://?” —wss://is WebSocket over TLS (port 443). Always use in production. Plainws://is sniffable and blocked by HTTPS pages. - “Why are client frames masked?” — XOR-masking with a per-frame key. NOT for security; defends against old HTTP proxies that might cache or misinterpret WebSocket bytes. Server-sent frames are not masked.
- “What’s
Sec-WebSocket-Keyfor?” — random nonce from the client. Server hashes (SHA1) with a fixed GUID and returns asSec-WebSocket-Accept. Proves the server is WebSocket-aware (not a confused HTTP server that 200s on any request).