How WebSockets Work: The Upgrade Handshake, Frame Protocol, and Bidirectional Communication From HTTP to Persistent Connection
A deep dive into the WebSocket protocol internals: the HTTP upgrade handshake with Sec-WebSocket-Key/Accept computation, the binary frame format, control frames, permessage-deflate compression, and production considerations for horizontal scaling, backpressure, and reconnection.
Most engineers understand what WebSockets do: they give you a persistent, bidirectional channel between browser and server without the overhead of repeated HTTP round trips. What fewer engineers understand is how that channel is actually established, what the wire format looks like, and what breaks at scale. This article covers the protocol from the ground up, including the upgrade handshake, frame structure, control frames, compression negotiation, and the production realities of running WebSocket servers at any meaningful load.
The HTTP Upgrade Handshake
A WebSocket connection starts as a regular HTTP/1.1 request. The client initiates an upgrade by sending a GET request with a specific set of headers.
GET /chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
The Sec-WebSocket-Key is a base64-encoded random 16-byte value generated fresh for each connection. It is not a credential — it exists solely to prevent response caching and to confirm the server genuinely understands the WebSocket protocol. The server computes a response key by concatenating the client’s key with a fixed magic string, hashing the result with SHA-1, and base64-encoding it.
import { createHash } from "crypto";
const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
function computeAcceptKey(clientKey: string): string {
return createHash("sha1")
.update(clientKey + WEBSOCKET_GUID)
.digest("base64");
}
// Example
const clientKey = "dGhlIHNhbXBsZSBub25jZQ==";
const acceptKey = computeAcceptKey(clientKey);
// acceptKey === "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
The server responds with HTTP 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After this exchange, the TCP connection that carried the HTTP handshake is repurposed. Neither side closes it. Both sides now speak the WebSocket framing protocol instead of HTTP. The HTTP layer is gone.
The Binary Frame Format
Every message sent over a WebSocket connection is divided into one or more frames. The frame header is compact, 2 to 14 bytes depending on payload size and masking.
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, if MASK set |
+---------------------------------------------------------------+
| Payload data |
+---------------------------------------------------------------+
Breaking this down:
FIN bit (1 bit): Set to 1 if this is the final fragment of a message. A single-frame message always has FIN=1. Multi-frame messages have FIN=0 on all fragments except the last.
RSV1, RSV2, RSV3 (1 bit each): Reserved for extensions. RSV1 is repurposed by the permessage-deflate extension to signal that a message is compressed.
Opcode (4 bits): Identifies the frame type. The defined opcodes are:
| Opcode | Hex | Meaning |
|---|---|---|
| 0 | 0x0 | Continuation frame |
| 1 | 0x1 | Text frame (UTF-8) |
| 2 | 0x2 | Binary frame |
| 8 | 0x8 | Connection Close |
| 9 | 0x9 | Ping |
| 10 | 0xA | Pong |
MASK bit (1 bit): The WebSocket spec requires all frames sent from client to server to be masked. Frames sent from server to client must not be masked.
Payload length (7 bits + optional extension): Values 0-125 are the actual payload length. A value of 126 means the next 2 bytes hold the real length as a 16-bit unsigned integer, supporting payloads up to 65535 bytes. A value of 127 means the next 8 bytes hold the real length as a 64-bit unsigned integer.
Masking key (4 bytes, present only when MASK=1): A random 32-bit value. The payload is XOR-masked byte by byte, cycling through the 4-byte key.
function maskPayload(payload: Buffer, maskingKey: Buffer): Buffer {
const result = Buffer.allocUnsafe(payload.length);
for (let i = 0; i < payload.length; i++) {
result[i] = payload[i] ^ maskingKey[i % 4];
}
return result;
}
function buildTextFrame(text: string): Buffer {
const payload = Buffer.from(text, "utf8");
const maskingKey = crypto.getRandomValues(new Uint8Array(4));
const masked = maskPayload(payload, Buffer.from(maskingKey));
const header = Buffer.allocUnsafe(6 + (payload.length > 125 ? 2 : 0));
let offset = 0;
// FIN=1, opcode=0x1 (text)
header[offset++] = 0x81;
if (payload.length <= 125) {
// MASK=1, 7-bit length
header[offset++] = 0x80 | payload.length;
} else {
// MASK=1, length=126, then 2-byte extended length
header[offset++] = 0x80 | 126;
header.writeUInt16BE(payload.length, offset);
offset += 2;
}
Buffer.from(maskingKey).copy(header, offset);
return Buffer.concat([header, masked]);
}
The masking requirement for client-to-server frames exists to defend against cache poisoning attacks on intermediary proxies that might misinterpret WebSocket frames as HTTP responses.
Data Frames vs Control Frames
Data frames carry application content. Control frames carry protocol-level signals. The key distinction is that control frames cannot be fragmented and their payload is limited to 125 bytes.
Ping and Pong
Either side can send a ping frame at any time. The recipient must respond with a pong containing the same payload data. This is the heartbeat mechanism: if you send pings at a regular interval and stop receiving pongs, the connection is dead and you can close the socket without waiting for TCP to time out.
class WebSocketHeartbeat {
private pingInterval: ReturnType<typeof setInterval> | null = null;
private pendingPong = false;
constructor(
private readonly ws: WebSocket,
private readonly intervalMs: number = 30_000
) {}
start(): void {
this.pingInterval = setInterval(() => {
if (this.pendingPong) {
// Previous ping went unanswered — connection is stale
this.ws.terminate();
return;
}
this.pendingPong = true;
this.ws.ping();
}, this.intervalMs);
this.ws.on("pong", () => {
this.pendingPong = false;
});
}
stop(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
}
Close Handshake
A graceful close requires a two-step exchange. The initiating side sends a Close frame with a 2-byte status code and an optional UTF-8 reason string. The receiving side must echo a Close frame back before closing the underlying TCP connection.
Common status codes:
| Code | Meaning |
|---|---|
| 1000 | Normal closure |
| 1001 | Going away (server restart, browser nav) |
| 1002 | Protocol error |
| 1003 | Unsupported data type |
| 1008 | Policy violation |
| 1011 | Internal server error |
If either side disappears without sending a Close frame (network cut, process crash), the other side learns about it when it next tries to write to or read from the socket and gets a TCP RST or a timeout from its ping/pong heartbeat.
Per-Message Compression with permessage-deflate
The permessage-deflate extension applies DEFLATE compression to each message’s payload before framing it. Negotiation happens during the HTTP upgrade via the Sec-WebSocket-Extensions header.
# Client request header
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
# Server response header (accepting with default window size)
Sec-WebSocket-Extensions: permessage-deflate
When the extension is active, RSV1=1 signals a compressed frame. The receiver inflates the payload after unmasking it.
The compression context can be shared across messages (stateful, better compression ratio) or reset per message (stateless, no context takeover). The no_context_takeover parameters control this:
Sec-WebSocket-Extensions: permessage-deflate;
client_no_context_takeover;
server_no_context_takeover
Context takeover gives better compression for correlated messages (e.g., JSON with repeated field names) at the cost of memory per connection on the server. At 100,000 concurrent connections, a sliding window of 32 KB per connection is 3.2 GB of compression state alone. For servers with large connection counts, server_no_context_takeover is often worth the compression ratio loss.
Connection Lifecycle
Client Server
| |
|--- HTTP GET /ws (Upgrade) ---------->|
|<-- 101 Switching Protocols ----------|
| | OPEN
|<-- Text/Binary frames -------------->|
|--- Text/Binary frames -------------->|
| | DATA EXCHANGE
|--- Ping ------------------------->---|
|<-- Pong -----------------------------|
| |
|--- Close (1000) ----------------->---|
|<-- Close (1000) ---------------------|
| | CLOSED
|=== TCP FIN ==========================|
The open event fires after the 101 response. Messages can flow in both directions simultaneously from that point. Either side can initiate close. After the close handshake completes, the TCP connection is torn down normally.
Abnormal closure happens when the TCP layer signals an error or when the heartbeat detects silence. In that case, no Close frame is exchanged and the close event fires with code 1006 (abnormal closure) on the client.
Tradeoffs: WebSockets vs Alternatives
| Dimension | WebSockets | Server-Sent Events (SSE) | HTTP Long Polling | HTTP/2 Server Push | WebTransport |
|---|---|---|---|---|---|
| Directionality | Full duplex | Server to client only | Server to client (per poll) | Server to client only | Full duplex |
| Client-to-server | Native, same connection | Separate HTTP requests | Separate HTTP requests | Separate HTTP requests | Native, same connection |
| Latency | Low (persistent connection) | Low (persistent connection) | High (round trip per message) | Low (but push is deprioritized) | Very low (QUIC, 0-RTT) |
| Connection overhead | One TCP handshake, kept open | One TCP handshake, kept open | Repeated TCP handshakes | Multiplexed on HTTP/2 stream | One QUIC handshake, kept open |
| Browser support | Universal | Universal (IE excluded) | Universal | Partial, inconsistent behavior | Chrome/Edge, limited elsewhere |
| Proxy/firewall issues | Common (port 80/443 helps) | Rare | Rare | Rare | Requires UDP (often blocked) |
| Scalability | Hard (sticky sessions or pub/sub) | Hard (same as WS) | Easier (stateless servers) | Easier (HTTP semantics) | Hard (same as WS) |
| Protocol complexity | Medium (framing, masking, opcodes) | Low (plain text, EventSource API) | Low (plain HTTP) | Low (HTTP headers) | High (QUIC, datagrams, streams) |
| Auto-reconnect | Manual implementation required | Built into EventSource API | Manual implementation required | Not applicable | Manual implementation required |
| Compression | permessage-deflate extension | HTTP transfer-encoding | HTTP transfer-encoding | HTTP/2 HPACK headers | Built-in QUIC |
SSE is often the right answer for notification feeds, live dashboards, and log tailing: you get server push without the complexity of bidirectional framing, and the browser’s EventSource API handles reconnection automatically. Use WebSockets when you genuinely need low-latency client-to-server messages on the same connection (collaborative editing, multiplayer games, trading UIs).
Production Considerations
Horizontal Scaling and Sticky Sessions
WebSockets are stateful. A connection is pinned to a single server process for its lifetime. When you add a second server behind a load balancer, you have two choices.
Sticky sessions (source IP or cookie affinity) route every request from the same client to the same server. This is simple but uneven: a client that reconnects after a server restart hits a random backend and may land on a different one. Sticky sessions also make autoscaling harder because draining a server requires waiting for all its connections to close naturally.
Pub/sub broadcast layer decouples connection state from message routing. Each server maintains its local connections, and all servers subscribe to a shared channel. When you want to send a message to a specific user, you publish to the channel and every server delivers it if the connection is local.
import { createClient } from "redis";
const redisPublisher = createClient({ url: process.env.REDIS_URL });
const redisSubscriber = redisPublisher.duplicate();
await redisPublisher.connect();
await redisSubscriber.connect();
// Map of userId -> WebSocket (local connections only)
const localConnections = new Map<string, WebSocket>();
// Every server subscribes to the broadcast channel
await redisSubscriber.subscribe("ws:broadcast", (message) => {
const { userId, payload } = JSON.parse(message);
const ws = localConnections.get(userId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
});
// To send a message to any user regardless of which server holds the connection
async function sendToUser(userId: string, payload: string): Promise<void> {
await redisPublisher.publish(
"ws:broadcast",
JSON.stringify({ userId, payload })
);
}
This pattern lets you scale horizontally without sticky sessions. The tradeoff is that every message is fanned out to all server instances, which adds latency and Redis load proportional to server count. For targeted delivery at high scale, a routing table that tracks which server holds each connection reduces fan-out to a single delivery.
File Descriptor and Connection Limits
Each WebSocket connection holds a file descriptor. Linux defaults are low:
# View current limits
ulimit -n # per-process open file limit (often 1024)
cat /proc/sys/fs/file-max # system-wide limit
# Raise the per-process limit
ulimit -n 100000
# Raise permanently in /etc/security/limits.conf
* soft nofile 100000
* hard nofile 100000
# Raise system-wide
sysctl -w fs.file-max=2000000
At 10,000 connections per process, each connection also holds receive/send buffers in kernel space (typically 4 KB to 128 KB per socket depending on tuning). A process holding 50,000 connections with default buffer sizes will consume several gigabytes of kernel memory before you add any application-layer state.
Backpressure
The WebSocket API in Node.js and browsers does not block on send. If you call ws.send() faster than the network can drain the buffer, the data accumulates in the kernel send buffer and eventually in an application-level buffer. When those buffers fill, you have no signal at the application layer that you are overloading the connection.
Node.js’s ws library exposes ws.bufferedAmount and the drain event for handling this:
function safeSend(ws: WebSocket, data: string): boolean {
if (ws.readyState !== WebSocket.OPEN) return false;
// bufferedAmount is the number of bytes queued but not yet sent
const HIGH_WATERMARK = 1024 * 64; // 64 KB
if ((ws as any).bufferedAmount > HIGH_WATERMARK) {
// Apply backpressure: drop or queue the message at the application layer
return false;
}
ws.send(data);
return true;
}
// Listen for the drain event to resume sending after the buffer clears
ws.on("drain", () => {
// Resume sending from your application-level queue
flushPendingMessages(ws);
});
Without backpressure handling, a slow client can cause your server process to accumulate unbounded memory for unsent data. In a game server or trading feed with high message rates, this is the most common cause of memory pressure and OOM crashes.
Reconnection with Exponential Backoff
Clients should never reconnect immediately after a disconnect. A mass reconnect event (server restart, network partition) generates a thundering herd that can overwhelm the server before it finishes starting.
class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private attempt = 0;
private readonly BASE_DELAY_MS = 500;
private readonly MAX_DELAY_MS = 30_000;
private readonly JITTER_FACTOR = 0.3;
constructor(private readonly url: string) {
this.connect();
}
private connect(): void {
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => {
this.attempt = 0; // Reset on successful connection
});
this.ws.addEventListener("close", (event) => {
if (event.code === 1000) return; // Intentional close, do not reconnect
const delay = this.computeDelay();
setTimeout(() => this.connect(), delay);
});
}
private computeDelay(): number {
const exponential = Math.min(
this.BASE_DELAY_MS * 2 ** this.attempt,
this.MAX_DELAY_MS
);
// Add jitter to spread reconnection attempts across the fleet
const jitter = exponential * this.JITTER_FACTOR * Math.random();
this.attempt++;
return exponential + jitter;
}
send(data: string): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
}
}
The jitter is not optional. Without it, every client that lost the connection at the same time reconnects at the same exponential intervals, creating synchronized waves of connection attempts. Full jitter distributes these attempts uniformly over the delay window.
Closing
WebSockets are a thin protocol layer on top of TCP with a small but precise specification. The upgrade handshake is one HTTP round trip, the frame format fits in a few dozen bytes of header for most messages, and the control frame semantics give you clean lifecycle management. The complexity is not in the protocol itself but in what happens when you run it at scale: connection affinity breaks horizontal scaling assumptions, the lack of built-in backpressure demands explicit handling, and naively managed file descriptor limits will cap your connection count long before your hardware does. Understand those constraints before you start building, and WebSockets become a predictable, well-specified tool rather than a source of mysterious production incidents.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.