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
- TCP, then TLS for
wss://. The client connects to port 80 forws://or 443 forwss://. Withwss://the TLS handshake comes first, so the upgrade request already travels encrypted. - The client sends an upgrade request: a
GETover HTTP/1.1 or later, with the headers below plus everything an ordinary request carries -Host, cookies, a path, a query string. - 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. - The server accepts with
101 Switching Protocols, repeatingUpgradeandConnectionand addingSec-WebSocket-Accept, at most one chosen subprotocol, and the extensions it agreed to. - 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.
- 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.
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):
= 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 CLOSEDThe 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
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:
@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):
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 maskedA 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:
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
websocketsdefaults against 316 KiB at zlib’s own, per thewebsocketsdocs.compression=Noneor--ws-per-message-deflate falseturns 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():
> 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
- 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.
- 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.
- 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, andbufferedAmountis 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_timeoutand an AWS ALB’s idle timeout both default to 60 s. - No CORS, awkward auth. There is no preflight, so check
Originyourself (WebSocket Security), and browsers cannot send anAuthorizationheader (WebSocket and SSE Authentication). wss://in production. HTTPS pages cannot openws://(mixed content), and cleartext upgrades are what middleboxes strip.
Related
Interview angle 4
- “Walk me through the WebSocket handshake.” An HTTP/1.1
GETwithUpgrade: websocket,Connection: Upgrade, a randomSec-WebSocket-KeyandSec-WebSocket-Version: 13, plusOriginand cookies from a browser. The server checks origin and credentials while it can still answer in HTTP, then sends101 Switching ProtocolswithSec-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.
- Follow-up: “The server closes unauthenticated sockets with 4001, the
client stops retrying on 4001, yet it retries forever. Why?” The close
runs before
- “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.