Home › Networking › Framing & serialization
algorithm low

WebSockets

Problem Web apps need the server to push updates to a browser the moment they happen, and the browser to answer on the same channel, without a new request for every update.

Full-duplex message framing over one upgraded HTTP connection; the standard way to push real-time data to browsers.

O(1) header parse per frame; O(n) unmasking of client payloads (8+ bytes per XOR with word-wide masking)
websocketrfc-6455framingbrowserreal-timepush
Overview

Browsers can't open raw TCP sockets, and HTTP polling wastes a request per update. A WebSocket starts as an HTTP/1.1 request with Upgrade: websocket; after a 101 response the same TCP (usually TLS) connection carries small binary frames in both directions. Each frame has a 2-14 byte header with an opcode and a variable-size length; client-to-server payloads are XOR-masked with a random 4-byte key so that intermediaries can't be tricked into treating them as HTTP.

Key facts

Use when

  • Server push and bidirectional messages with browsers: chat, live games (turn-based or slow real-time), dashboards, collaborative editing, notifications.
  • You want one long-lived connection per client through standard HTTPS infrastructure.
  • Games (Lichess lila-ws), chat (Discord Gateway) and market data (Coinbase Exchange feed).

Avoid when

  • One-way server → client streams: Server-Sent Events are simpler and work over HTTP/2.
  • Fast-action games needing unreliable delivery: use WebRTC data channels or WebTransport (QUIC datagrams).
  • Request/response APIs: plain HTTP caches, retries and load-balances better.

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.

  1. Handshake: client sends GET with Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Version: 13 and Sec-WebSocket-Key (16 random bytes, base64). Server replies 101 Switching Protocols with Sec-WebSocket-Accept = base64(SHA-1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")). Check Origin here: browsers send cookies on cross-site WebSocket requests.
  2. 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 ...
  1. 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.
  2. 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.
  3. 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-deflate with 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 lower max_window_bits for 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-deflate without context takeover, or lower max_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

Server-Sent Eventsone-way, text, auto-reconnect with Last-Event-ID, plain HTTP.
WebTransportQUIC streams and unreliable datagrams to browsers; no head-of-line blocking.
WebRTC data channelsSCTP over DTLS, unreliable/unordered modes, peer-to-peer; heavy setup.
Raw length-prefix framing over TCPfor native clients; smaller headers, no handshake.

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

namelangrepoinstallverdictlicencehealthas_ofnotes
tokio-tungsteniteRustsnapview/tokio-tungstenitecargo add tokio-tungstenite————async client and server on tungstenite; most used Rust option
fastwebsocketsRustdenoland/fastwebsocketscargo add fastwebsockets————minimal, fast; used by Deno; works with hyper
uWebSocketsC++uNetworking/uWebSocketsheader-only C++ (see repo); Node binding: uWebSockets.js————epoll/io_uring server, built-in pub/sub, permessage-deflate
coder/websocketGocoder/websocketgo get github.com/coder/websocket————context-aware API, successor of nhooyr.io/websocket
wsJavaScriptwebsockets/wsnpm 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

Used by

realtime query subscriptions · plannedLive queries that push incremental result diffs to clients

Systems (62):

Alternatives and related

Server-sent eventsA service must push a steady flow of small updates to many clients that only listen, over ordinary HTTP, and resume without gaps or duplicates after the connection drops, at each reconnect.Long pollingA client behind ordinary HTTP infrastructure needs to learn about server-side changes within moments, without the server being able to call it and without wasting a request every second on "nothing yet".Length-prefix framingTwo programs exchange discrete messages over a connection that delivers an unstructured stream of bytes. The receiver must find where each message starts and ends, even when data arrives split or merged.epoll event loopA server must keep tens of thousands of network connections open and react to whichever have data, without a thread per connection and without checking every connection on each wakeup.TLS 1.3Any network service must keep traffic between clients, or between internal services, private, tamper-proof and authenticated to the right server, without hand-building a secure channel.QUICApplications on slow, lossy or mobile networks need a connection that starts quickly, survives a change of network, and does not let one lost packet stall unrelated data, all while staying encrypted.Exponential backoff with jitterClients of a remote service that fails or throttles must retry in a way that does not hammer it in synchronized waves while it recovers: timeouts, 429/503 errors, reconnect loops.Reliable UDPReal-time systems such as live media, robot control, trading feeds and interactive apps need a network transport where old lost data does not hold up fresh data, yet some important messages still arrive. TCP's strict delivery stalls everything after a loss.