Deep dive
How it works
In: bytes of a TCP/TLS stream after a successful upgrade · Out: whole messages (text, binary, control) in both directions · State: per connection: partial-message buffer, opcode of the message in progress, close state, heartbeat timer · Cost: O(1) per frame header; O(n) to unmask client payloads.
- Handshake: client sends
GETwithUpgrade: websocket,Connection: Upgrade,Sec-WebSocket-Version: 13andSec-WebSocket-Key(16 random bytes, base64). Server replies101 Switching ProtocolswithSec-WebSocket-Accept = base64(SHA-1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")). CheckOriginhere: browsers send cookies on cross-site WebSocket requests. - Frames:
0 1 2 3
|F|R|R|R| opcode|M| len (7) | ext len (16 or 64, if len = 126/127)
|I|S|S|S| (4) |A| |
|N|V|V|V| |S| | mask key (32, if MASK) | payload ...
- Messages may be fragmented: first frame carries the opcode with FIN=0, then continuation frames, last has FIN=1. Control frames may interleave between fragments.
- Close: send a close frame (2-byte code + reason), wait for the peer's close, then the server closes TCP. Codes: 1000 normal, 1001 going away, 1008 policy, 1009 too big, 1011 server error; 1006 means "closed without a close frame" and is never sent.
- Extensions:
permessage-deflate(RFC 7692) compresses messages, negotiated in the handshake.
Code
// Server-side frame codec. Cargo: sha1 = "0.10", base64 = "0.22".
use base64::{engine::general_purpose::STANDARD, Engine};
use sha1::{Digest, Sha1};
pub fn accept_key(client_key: &str) -> String {
let mut h = Sha1::new();
h.update(client_key.trim().as_bytes());
h.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
STANDARD.encode(h.finalize())
}
#[derive(Debug, PartialEq)]
pub struct Frame { pub fin: bool, pub opcode: u8, pub payload_at: usize, pub len: usize }
#[derive(Debug)]
pub enum WsError { Protocol(&'static str), TooLarge }
/// Parse one client frame at the start of `buf` and unmask it in place.
/// Ok(None) = need more bytes. Total frame size = payload_at + len.
pub fn parse_client_frame(buf: &mut [u8], max: usize) -> Result<Option<Frame>, WsError> {
if buf.len() < 2 { return Ok(None); }
let (b0, b1) = (buf[0], buf[1]);
if b0 & 0x70 != 0 { return Err(WsError::Protocol("RSV bits without extension")); }
if b1 & 0x80 == 0 { return Err(WsError::Protocol("client frames must be masked")); }
let opcode = b0 & 0x0f;
let (len, mut at) = match b1 & 0x7f {
126 if buf.len() >= 4 => (u16::from_be_bytes([buf[2], buf[3]]) as u64, 4),
127 if buf.len() >= 10 => (u64::from_be_bytes(buf[2..10].try_into().unwrap()), 10),
126 | 127 => return Ok(None),
n => (n as u64, 2),
};
if opcode >= 8 && (len > 125 || b0 & 0x80 == 0) {
return Err(WsError::Protocol("bad control frame"));
}
if len > max as u64 { return Err(WsError::TooLarge); } // close with 1009
let len = len as usize;
if buf.len() < at + 4 + len { return Ok(None); }
let key: [u8; 4] = buf[at..at + 4].try_into().unwrap();
at += 4;
unmask(&mut buf[at..at + len], key);
Ok(Some(Frame { fin: b0 & 0x80 != 0, opcode, payload_at: at, len }))
}
/// XOR 8 bytes at a time; the key repeats every 4 bytes, so a u64 of key||key works.
fn unmask(p: &mut [u8], key: [u8; 4]) {
let k8 = u64::from_ne_bytes([key[0], key[1], key[2], key[3], key[0], key[1], key[2], key[3]]);
let mut chunks = p.chunks_exact_mut(8);
for c in &mut chunks {
let v = u64::from_ne_bytes((&*c).try_into().unwrap()) ^ k8;
c.copy_from_slice(&v.to_ne_bytes());
}
for (i, b) in chunks.into_remainder().iter_mut().enumerate() { *b ^= key[i % 4]; }
}
/// Server -> client frame (unmasked), single fragment.
pub fn encode_frame(out: &mut Vec<u8>, opcode: u8, payload: &[u8]) {
out.push(0x80 | opcode);
match payload.len() {
n if n <= 125 => out.push(n as u8),
n if n <= 0xffff => { out.push(126); out.extend_from_slice(&(n as u16).to_be_bytes()); }
n => { out.push(127); out.extend_from_slice(&(n as u64).to_be_bytes()); }
}
out.extend_from_slice(payload);
}
#[test]
fn rfc6455_examples() {
assert_eq!(accept_key("dGhlIHNhbXBsZSBub25jZQ=="), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
// Masked "Hello" from RFC 6455 section 5.7.
let mut f = [0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58];
let fr = parse_client_frame(&mut f, 1 << 20).unwrap().unwrap();
assert_eq!(&f[fr.payload_at..fr.payload_at + fr.len], b"Hello");
}
Complexity and constants
- Per frame: O(1) header parse; unmasking is a streaming XOR at memory bandwidth (order of 10 GB/s per core with word-wide or SIMD XOR, order of 1 GB/s byte-by-byte).
- Memory per idle connection: dominated by kernel socket buffers and TLS state; order of 10-50 KB with tuned buffers, so 100k-1M connections per large box is feasible.
permessage-deflatewith context takeover keeps a zlib window per direction (order of 32-64 KB plus compressor state each); at 1M connections that is 10s of GB. Disable context takeover or lowermax_window_bitsfor many small clients.
Efficiency
- Cost: 2-14 B header per frame (+4 B mask from clients); unmasking runs near memory bandwidth, order of 10 GB/s word-wide against order of 1 GB/s byte-wise. An idle connection holds order of 10-50 KB, so 100k-1M fit on a large box. The bound is memory per connection and wake-ups per small message, not CPU.
- Levers:
- word-wide or SIMD unmask — order of 10x over byte loops — large client frames
permessage-deflatewithout context takeover, or lowermax_window_bits— saves the 32-64 KB window per direction (RFC 7692) — many small clients- encode a broadcast once, send the same bytes to all subscribers — encode cost divided by subscribers (order of) — pub/sub fan-out
- batch writes per tick — fewer syscalls and TLS records — chatty streams
- sequence numbers, short replay buffer, jittered reconnect — cheap resume instead of full resync (Discord gateway pattern) — mobile clients
- Naive → standard → state of the art:
- HTTP polling every second · one request and response with full headers per client per second, even when idle
- WebSocket, one write per message, deflate with context takeover · 2-14 B header; tens of KB deflate state per connection
- WebSocket with 20-30 s pings, one encode per broadcast, batched writes, epoll or io_uring server, deflate only where it pays · 100k-1M connections per large box
Implementation notes
- Run it behind TLS (
wss://): unencrypted WebSockets are frequently broken by middleboxes. - Reverse proxies need explicit upgrade support and long idle timeouts (nginx:
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s;). - Heartbeats: server pings every 20-30 s, drops the client after 2-3 missed pongs. Browsers answer pings automatically but JS cannot send pings, so app-level heartbeats are common.
- Backpressure: the browser API only exposes
bufferedAmount; on the server, cap each client's outbound queue and drop or disconnect slow consumers instead of buffering unbounded. - Resume: give each server→client message a sequence number; on reconnect the client sends the last seen number and the server replays from a short buffer (Discord gateway pattern). Reconnect with exponential backoff and jitter to avoid reconnect storms after a deploy.
- Load balancing: long-lived connections don't rebalance; drain servers gradually on deploys and spread reconnects over minutes.
- Over HTTP/2 (RFC 8441) and HTTP/3 (RFC 9220) WebSockets run as streams via extended CONNECT; support is still partial.
Pitfalls
- Not checking
Origin: cross-site WebSocket hijacking with the victim's cookies. - Treating a frame as a message: fragmented messages and interleaved control frames break naive parsers.
- Accepting unmasked client frames or masked server frames: protocol violation; fail the connection.
- Validating UTF-8 only per frame: a multi-byte character can span fragments; validate the reassembled message (streaming validator).
- Unbounded message size: the 64-bit length field lets a client claim exabytes; enforce a max (e.g. 1-16 MB) and close with 1009.
Variants and alternatives
Last-Event-ID, plain HTTP.In production
Lichess (lila-ws), Discord gateway, Slack, trading UIs and exchange market-data feeds, Phoenix Channels (Elixir), Socket.IO (WebSocket transport), GraphQL subscriptions. Libraries: tokio-tungstenite, uWebSockets, Gorilla/coder websocket (Go), ws (Node).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| tokio-tungstenite | Rust | snapview/tokio-tungstenite | cargo add tokio-tungstenite | — | — | — | — | async client and server on tungstenite; most used Rust option |
| fastwebsockets | Rust | denoland/fastwebsockets | cargo add fastwebsockets | — | — | — | — | minimal, fast; used by Deno; works with hyper |
| uWebSockets | C++ | uNetworking/uWebSockets | header-only C++ (see repo); Node binding: uWebSockets.js | — | — | — | — | epoll/io_uring server, built-in pub/sub, permessage-deflate |
| coder/websocket | Go | coder/websocket | go get github.com/coder/websocket | — | — | — | — | context-aware API, successor of nhooyr.io/websocket |
| ws | JavaScript | websockets/ws | npm install ws | — | — | — | — | de facto Node.js WebSocket library |
Sources
- Fette, Melnikov, RFC 6455: The WebSocket Protocol, 2011. rfc
- Yoshino, RFC 7692: Compression Extensions for WebSocket, 2015. rfc
- McManus, RFC 8441: Bootstrapping WebSockets with HTTP/2, 2018; Hamilton, RFC 9220: Bootstrapping WebSockets with HTTP/3, 2022. 8441 · 9220
- Huang, Chen, Barth, Rescorla, Jackson, Talking to Yourself for Fun and Profit, W2SP, 2011. pdf