Backend / Protocols / WebSockets / 01_websocket_protocol.md

The WebSocket protocol

Updated 4 interview angles 17 min read source
On this page11
  1. The handshake, step by step
  2. Over HTTP/2 and HTTP/
  3. Frames
  4. Masking protects proxies, not your data
  5. Messages and fragmentation
  6. Extensions and subprotocols
  7. Ping and pong
  8. Closing
  9. When to choose a WebSocket
  10. Related
  11. Interview angle

The WebSocket protocol

A WebSocket is a full-duplex, message-oriented channel over one TCP connection, standardised as RFC 6455 in December 2011. It opens as an ordinary HTTP/1.1 request asking to switch protocols. Once the server answers 101 Switching Protocols, HTTP is over on that connection: either side sends frames whenever it likes, until one of them sends a Close frame.

This note is the entry point to the series: the handshake, the frames, the connection lifecycle, and when a WebSocket is the right tool at all. Examples use FastAPI on uvicorn 0.54 and websockets 17.2 on Python 3.14.

The handshake, step by step

  1. TCP, then TLS for wss://. The client connects to port 80 for ws:// or 443 for wss://. With wss:// the TLS handshake comes first, so the upgrade request already travels encrypted.
  2. The client sends an upgrade request: a GET over HTTP/1.1 or later, with the headers below plus everything an ordinary request carries - Host, cookies, a path, a query string.
  3. The server decides while it can still speak HTTP. It checks the version, the Origin, credentials and rate limits, and can refuse with any status code: 401, 403, 429, 503.
  4. The server accepts with 101 Switching Protocols, repeating Upgrade and Connection and adding Sec-WebSocket-Accept, at most one chosen subprotocol, and the extensions it agreed to.
  5. The client checks the answer. The status must be 101, the accept value must match its key, and any subprotocol or extension must be one it offered; anything else fails the connection.
  6. From the next byte, both sides speak frames. Nothing on the connection is HTTP any more, to the endpoints or to any proxy in between.
Request header What it does
Upgrade: websocket names the protocol to switch to
Connection: Upgrade makes Upgrade hop-by-hop: this connection only
Sec-WebSocket-Key 16 random bytes, base64: a nonce, not a secret
Sec-WebSocket-Version always 13, the only version RFC 6455 defines
Origin sent by every browser; the cross-site check reads it
Sec-WebSocket-Protocol optional subprotocols, most preferred first
Sec-WebSocket-Extensions optional extensions, e.g. permessage-deflate

Because Upgrade is hop-by-hop, a reverse proxy drops it unless told to forward it - the reason nginx needs explicit Upgrade and Connection lines (Nginx WebSocket Proxying). The Sec- prefix is a defence of its own: browsers never let a script set a Sec- header through fetch() or XMLHttpRequest, so a hostile page cannot forge a handshake with an ordinary request (RFC 6455 §1.3).

A minimal server

An echo endpoint is enough to watch the whole lifecycle; accept() is the call that sends the 101.

python
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()

@app.websocket("/ws")
async def echo(ws: WebSocket):
    await ws.accept()  # sends the 101
    try:
        while True:
            await ws.send_text("echo: " + await ws.receive_text())
    except WebSocketDisconnect as exc:
        print("client closed:", exc.code)

What the client sees

A websockets client with its logger at DEBUG and compression off prints every step as it connects, sends hello and leaves its async with block (response headers trimmed):

text
= connection is CONNECTING
> GET /ws HTTP/1.1
> Host: 127.0.0.1:8765
> Upgrade: websocket
> Connection: Upgrade
> Sec-WebSocket-Key: v5MVJoGZceBXkZKlt9HSaA==
> Sec-WebSocket-Version: 13
< HTTP/1.1 101 Switching Protocols
< Upgrade: websocket
< Connection: Upgrade
< Sec-WebSocket-Accept: zhMn8szSo+5IlJf0SxkirXsVOsI=
= connection is OPEN
> TEXT 'hello' [5 bytes]
< TEXT 'echo: hello' [11 bytes]
> CLOSE 1000 (OK) [2 bytes]
= connection is CLOSING
< CLOSE 1000 (OK) [2 bytes]
> EOF
= connection is CLOSED

The four states are the browser’s readyState values 0 to 3. From CLOSE on is the closing handshake: the client sends Close 1000, the server answers with its own, and TCP shuts. With compression on, the client would also have offered permessage-deflate.

What the accept value proves

python
import base64, hashlib

GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"  # fixed by RFC 6455

def accept(key: str) -> str:
    digest = hashlib.sha1((key + GUID).encode("ascii")).digest()
    return base64.b64encode(digest).decode("ascii")

print(accept("v5MVJoGZceBXkZKlt9HSaA=="))  # zhMn8szSo+5IlJf0SxkirXsVOsI=
print(accept("dGhlIHNhbXBsZSBub25jZQ=="))  # s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The first key is the one in the log, the second the RFC’s own example. The value proves the server read this handshake and speaks the protocol: a plain HTTP server that answers anything with 200 cannot produce it, and a response replayed from a cache carries the value for another key.

It is not authentication. Anyone can compute it, which is why SHA-1 is fine here; identity and permission are decided before the 101, from Origin and credentials.

Refusing a connection: before or after the 101

Where the server says no decides what the client learns. In Starlette, and so in FastAPI, close() before accept() cannot send a Close frame because there is no WebSocket yet, so the server answers the upgrade with a plain 403, as the ASGI spec requires:

python
@app.websocket("/private")
async def private(ws: WebSocket):
    if "session" not in ws.cookies:
        await ws.close(code=4001)  # before accept(): sent as HTTP 403
        return
    await ws.accept()

Written instead as accept() and then close(4001, "login required"), the endpoint sends a 101 and then a real Close frame. What the page sees:

Server does Browser onclose gets Why
close(4001) first 1006, wasClean: false the handshake failed: 403
accept(), close(4001) 4001 and the reason a real Close frame

Browsers report every failed handshake as 1006 and hide the HTTP status from the page on purpose (WHATWG WebSockets standard); the 403 shows only in DevTools. A client coded to stop retrying on 4001 never sees one from a pre-accept refusal, and retries forever.

So refuse before the 101 for traffic you never want - a foreign Origin, bots, rate limits - and no socket ever exists. Accept and then close with an application code when a legitimate client must learn why, such as an expired session that needs a fresh login rather than a retry.

Starlette’s send_denial_response() swaps the bare 403 for any HTTP response, readable by non-browser clients only. Auth patterns are in WebSocket and SSE Authentication.

Over HTTP/2 and HTTP/3

HTTP/2 has no Upgrade - connection-specific headers are banned, and one connection carries many streams - so RFC 8441 (2018) runs a WebSocket on a single stream. The server advertises SETTINGS_ENABLE_CONNECT_PROTOCOL = 1, the client sends an extended CONNECT with :protocol = websocket, a 200 accepts it, and the stream then carries ordinary RFC 6455 frames, masking included. RFC 9220 (2022) does the same over HTTP/3.

Chrome and Firefox use RFC 8441 whenever their HTTP/2 connection to the origin advertises it, so advertising it is a promise. In mattermost#30285 (February 2025) every Chrome and Firefox socket behind an HTTP/2 proxy failed with 1006, while Safari, still on HTTP/1.1, connected; forcing HTTP/1.1 on that path is the quick fix.

nginx implements neither RFC (as of 1.31.6), so behind nginx browsers fall back to the HTTP/1.1 upgrade; uvicorn speaks HTTP/1.1 only, and Hypercorn serves WebSockets over HTTP/2. Application code does not change either way, and the trade is one shared TCP and TLS connection against sharing its head-of-line blocking (HTTP versions: 1.1, 2 and 3).

Frames

After the 101 every byte belongs to a frame (RFC 6455 §5.2):

text
byte 0     FIN     1 on the last frame of a message
           RSV1-3  0 unless an extension uses them
           opcode  4 bits: the frame type, below
byte 1     MASK    1 on client-to-server frames only
           length  7 bits: 0-125 is the length itself
+2 or +8   extended length, when that field is 126 or 127
+4         masking key, present when MASK is 1
rest       payload, XOR-ed with the key when masked

A length field of 126 means a 16-bit length follows, 127 a 64-bit one; the shortest form is mandatory, and a 64-bit length keeps its top bit clear. So a frame header is 2 bytes for payloads up to 125, 4 up to 65,535 and 10 beyond, plus the 4-byte key on client frames: 2 to 14 bytes, where an HTTP request spends hundreds on headers.

Opcodes

Opcode Frame Notes
0x0 Continuation the next fragment of a message
0x1 Text the whole message must be valid UTF-8
0x2 Binary arbitrary bytes
0x3-0x7 reserved future data frames
0x8 Close optional 2-byte code and UTF-8 reason
0x9 Ping up to 125 bytes; must be answered
0xA Pong echoes the ping’s payload
0xB-0xF reserved future control frames

Control frames, 0x8 and up, carry at most 125 bytes, are never fragmented, and may arrive between the fragments of a data message, which is how a ping gets through while a large message is still being sent. A text message that is not valid UTF-8 fails the connection with 1007.

A frame, byte by byte

The client’s hello from the log above, on the wire:

text
81              FIN=1, opcode 0x1 (text)
85              MASK=1, payload length 5
3b ce 33 52     masking key, fresh for this frame
53 ab 5f 3e 54  "hello" XOR key: 0x68 ^ 0x3b = 0x53, ...

The echo comes back as 81 0b and the 11 bytes of echo: hello in clear: no mask bit, no key. Send the client frame unmasked instead and the server answers at once with 88 13 03 ea and the reason incorrect masking - a Close carrying 1002, protocol error. RFC 6455 §5.1 makes a server close on any unmasked client frame, and a client on any masked server frame.

Masking protects proxies, not your data

Every client frame is XOR-ed with a fresh 4-byte key, and the key travels in clear in the same frame, so masking hides nothing from anyone who can see the bytes. That is by design: masking is a security control for the network between client and server, not for the endpoints (RFC 6455 §10.3).

The attack it stops was shown while the protocol was being drafted, in Huang, Chen, Barth, Rescorla and Jackson’s “Talking to Yourself for Fun and Profit” (W2SP 2011). A hostile page opens a socket to the attacker’s server through a transparent proxy that does not understand Upgrade, and sends bytes that look exactly like an HTTP request for a popular script on another host. The proxy caches the attacker’s lookalike reply under that third-party URL.

Their ad-network experiment poisoned a cache on 8 of the 47,338 impressions that completed an Upgrade-style handshake: rare, but one poisoned enterprise proxy serves the payload to the whole enterprise.

With a key the script cannot predict, the script no longer chooses the bytes on the wire and cannot write a fake request. Hence the rules: the key comes from a strong random source and changes every frame, and a payload cannot change once its frame starts sending - either gap would let the attacker pre-mask. Server frames go unmasked because the attack needs a forged request, and server bytes can only pose as a response.

The paper proposed masking with AES-128-CTR; the working group adopted a cheaper variant, a plain XOR with a fresh 32-bit key per frame. Masking gives no confidentiality or integrity - that is TLS’s job, so use wss://.

Messages and fragmentation

Applications send messages; frames carry them. A message is a single frame with FIN=1, or a run: the first frame carries the opcode (text or binary) with FIN=0, the middle ones are continuations (0x0), and the last continuation has FIN=1. Fragmenting lets a sender stream a message whose final size it does not know yet.

  • Frame boundaries mean nothing. An intermediary may re-fragment a message, so never attach meaning to where a frame ends.
  • Messages never interleave. Only control frames may cut between the fragments of a message, so a 100 MB message delays every other message on that socket. Split big payloads into separate messages.
  • Size limits cover the whole message, and past one the receiver closes with 1009. The defaults differ - 16 MiB in uvicorn, 1 MiB in websockets - so a 2 MB echo passes the server and is refused by a default client.

Extensions and subprotocols

permessage-deflate

RFC 7692 compresses each message with DEFLATE. Browsers and the websockets client offer it, and uvicorn accepts it, all by default.

  • The first frame of a compressed message has RSV1 set; control frames are never compressed.
  • By default each side keeps its compression context between messages (context takeover), so repetitive JSON shrinks well - but every connection holds zlib state: 64 KiB at the websockets defaults against 316 KiB at zlib’s own, per the websockets docs. compression=None or --ws-per-message-deflate false turns it off.
  • RFC 7692 warns about CRIME-style attacks: compressing attacker-influenced text in one context with a secret leaks the secret through message sizes. Keep tokens out of compressed streams that also carry user input.
  • Skip it for payloads that are already compressed: images, audio, video.

Subprotocols

Sec-WebSocket-Protocol lists the application protocols the client speaks, most preferred first: graphql-transport-ws, mqtt, wamp.2.json, or your own chat.v2. The server must pick exactly one of them, or none. FastAPI reads the offer from ws.scope["subprotocols"] and answers with await ws.accept(subprotocol="chat.v2").

The trap: a browser that offered subprotocols fails the connection if the server picks none (WHATWG WebSockets standard), and the page sees only 1006. That is how the “token in the subprotocol” auth trick usually breaks (WebSocket and SSE Authentication).

Ping and pong

A Ping (0x9) may carry up to 125 bytes, and the receiver must answer with a Pong (0xA) echoing them, unless it has already received a Close. With several pings outstanding, answering the latest is enough, and an unsolicited Pong is allowed as a one-way heartbeat. After await ws.ping():

text
> PING 9c 39 17 05 [binary, 4 bytes]
< PONG 9c 39 17 05 [binary, 4 bytes]

The echoed payload is how a sender matches a pong to its ping; websockets pings with 4 random bytes and ignores pongs that match none. Pings exist because a silent TCP connection can look open for hours after the peer has gone, and because NATs, firewalls and load balancers drop connections they think idle.

Both websockets and uvicorn ping every 20 s by default and close with 1011 when no pong arrives within another 20 s. TCP keepalive is no substitute: it is off by default on most systems, and when enabled it waits two hours before the first probe.

Browsers answer pings on their own, but the WebSocket API lets page JS neither send nor see one, so a browser client that must notice a dead connection sends application-level heartbeats. Intervals against proxy timeouts are in WebSocket Connection Management.

Closing

  1. One side sends a Close frame: an optional 2-byte status code and a UTF-8 reason of at most 123 bytes. After that it sends no more data frames.
  2. The other side answers with its own Close, usually echoing the code; it may first finish a message it was in the middle of sending.
  3. With Close frames exchanged both ways, TCP closes - by the server first where possible, so the server holds TIME_WAIT rather than the client (RFC 6455 §7.1.1).

Close codes

Code Meaning When you see it
1000 Normal closure the work is done
1001 Going away page navigated away, server shutting down
1002 Protocol error an unmasked client frame, as above
1003 Unsupported data binary sent to a text-only endpoint
1005 No status received local only: a Close without a code
1006 Abnormal closure local only: no Close frame at all
1007 Invalid payload a text message that is not UTF-8
1008 Policy violation a generic refusal, e.g. a bad Origin
1009 Message too big over the receiver’s size limit
1010 Mandatory extension client only: a required extension refused
1011 Internal error server failure; a missed pong
1012 Service restart reconnect after a random 5-30 s delay
1013 Try again later overloaded; retry later or elsewhere
1014 Bad gateway a proxy got a bad upstream response
1015 TLS handshake local only: TLS failed
3000-3999 Registered libraries and frameworks, via IANA
4000-4999 Private use your application’s own codes

1012 to 1014 are not in RFC 6455; they were registered with IANA in 2012 and 2016, and their rows follow those registrations. 1005, 1006 and 1015 never appear on the wire: they are what an endpoint reports to its own application when the close was not a clean exchange.

1006 is the code that fills dashboards. No Close frame arrived: the network dropped, a load balancer hit its idle timeout, the process died, or the handshake was refused. The browser will not say which, so correlate 1006 spikes with deploys, proxy timeouts and missed pongs.

Page JS may close only with 1000 or 3000-4999; any other code throws an InvalidAccessError. Uvicorn closes every open socket with 1012 when it shuts down, before the app’s lifespan shutdown runs, so clients should treat 1012 like 1001 and reconnect with jitter. Draining a deploy without a reconnect storm is in Scaling WebSocket Applications.

When to choose a WebSocket

Traffic Choose Because
Server to client only SSE plain HTTP; reconnect and resume built in
Updates minutes apart Polling cacheable, stateless, nothing to run
Rare but urgent Long polling prompt delivery over any HTTP path
Both ways, often WebSocket one connection, 2-14 bytes of framing
Binary both ways WebSocket binary frames; SSE is text only
Service to service gRPC streaming typed contracts, deadlines, HTTP/2

A WebSocket earns its cost when the client talks as often as the server: chat with typing indicators, collaborative editing, game input, voice in both directions. Most “real-time” features are one-way, and there SSE is simpler: LLM token streaming is a POST whose response is an event stream (Server-Sent Events (SSE)).

When updates are minutes apart, polling costs less than any held connection (Short Polling vs Long Polling). The full comparison is in WebSocket vs Alternatives.

What you sign up for with a WebSocket:

  • State pinned to one process. Reaching a user connected to another server needs a broker (Scaling WebSocket Applications).
  • No reconnect, no replay. Unlike EventSource, the browser does nothing when the socket drops, and messages sent meanwhile are gone (WebSocket Connection Management).
  • No backpressure in the browser. send() never blocks; the browser queues, and bufferedAmount is the only sign the network is behind.
  • Every hop must cooperate. Each proxy must forward the upgrade and tolerate idle connections; nginx’s proxy_read_timeout and an AWS ALB’s idle timeout both default to 60 s.
  • No CORS, awkward auth. There is no preflight, so check Origin yourself (WebSocket Security), and browsers cannot send an Authorization header (WebSocket and SSE Authentication).
  • wss:// in production. HTTPS pages cannot open ws:// (mixed content), and cleartext upgrades are what middleboxes strip.

Interview angle 4

  • “Walk me through the WebSocket handshake.” An HTTP/1.1 GET with Upgrade: websocket, Connection: Upgrade, a random Sec-WebSocket-Key and Sec-WebSocket-Version: 13, plus Origin and cookies from a browser. The server checks origin and credentials while it can still answer in HTTP, then sends 101 Switching Protocols with Sec-WebSocket-Accept, the base64 SHA-1 of the key plus a fixed GUID. From then on, only frames.
    • Follow-up: “So the accept header authenticates the server?” No. Anyone can compute it; it proves the server understood this handshake, so a plain HTTP server or a cached response cannot pass for a WebSocket. Trust comes from TLS, and permission from the checks made before the 101.
  • “Why do clients mask frames - is it a security feature?” Yes, for the network rather than the endpoints. Unmasked, a script could make the browser emit bytes that a transparent proxy parses as an HTTP request, and poison its cache with the reply. A fresh, unpredictable key per frame takes the bytes out of the script’s control. There is no confidentiality in it: the key is in the frame, and that job belongs to TLS.
    • Follow-up: “Then why aren’t server frames masked?” The attack needs a forged request, and server bytes can only pose as a response. A server that receives an unmasked client frame must close the connection: 1002.
  • “Your dashboard shows a spike in close code 1006. What happened?” No Close frame arrived; 1006 is never sent on the wire. A network drop, a load balancer’s idle timeout, a crashed process or a refused handshake - and the browser will not say which, so correlate with deploys, proxy timeouts and missed pongs.
    • Follow-up: “The server closes unauthenticated sockets with 4001, the client stops retrying on 4001, yet it retries forever. Why?” The close runs before accept(), so it goes out as HTTP 403, and browsers report every failed handshake as 1006. Accept first, then close with 4001.
  • “WebSocket or SSE for streaming LLM output to a browser?” SSE. The traffic is one-way and it stays plain HTTP: a POST whose response is an event stream, which multiplexes over HTTP/2 and passes proxies that stream.
    • Follow-up: “What would make you switch?” The client streaming too: voice going up while text and speech come down, with the user able to cut in mid-answer. One full-duplex connection with binary frames then beats an event stream plus a stream of POSTs.