Protocol Handshake Mechanics #
A WebSocket connection does not begin as a WebSocket. It begins as an ordinary HTTP/1.1 GET request that asks the server to switch protocols. When that switch is mishandled — a reverse proxy strips the Upgrade header, a CDN buffers the response, or the server fails to echo the cryptographic accept token — the browser surfaces a single opaque error: WebSocket connection to 'wss://…' failed. No status code in the console, no frame on the wire, nothing to grep. This page dissects the handshake byte by byte so you can tell which of those failure points actually fired, and fix it at the layer where it broke.
Prerequisites #
This is the lowest layer of Real-Time Protocol Selection & Architecture; read that overview first if you are still deciding whether WebSockets are the right transport at all — the WebSocket vs SSE vs WebRTC comparison covers that trade-off. Before debugging a handshake you should have:
- A TLS-terminating endpoint serving
wss://(plainws://is fine on localhost, but every production handshake rides on TLS). - A reverse proxy that passes
Upgrade/Connectionheaders untouched. If you run nginx, configuring nginx for WebSocket upgrades is mandatory reading — the default proxy config drops these headers and breaks the handshake silently. - Node.js 18+ with the
wspackage, or any server library whose handshake hooks you can inspect. curland browser DevTools for the verification step.
The handshake as a sequence #
The exchange is a single HTTP request/response pair. The client offers an Upgrade, the server proves it understood the WebSocket protocol by hashing the client’s key, and only then does the TCP socket stop speaking HTTP and start carrying WebSocket frames.
The cryptographic step is deliberately simple and is not about security — it is about proving the responder is a real WebSocket endpoint and not a caching proxy replaying a stale 101. The server takes the client’s Sec-WebSocket-Key, appends the fixed RFC 6455 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, computes the SHA-1 digest, and base64-encodes it into Sec-WebSocket-Accept. The browser recomputes the same value and aborts the connection if it does not match.
Core implementation #
Most engineers never write the handshake by hand because ws does it for them. But you will hook into it — to authenticate, to validate Origin, or to log what each side actually offered. The block below is a standard ws server that intercepts the upgrade, reproduces the accept-token computation so you can see exactly what the library does, and negotiates a subprotocol.
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { WebSocketServer } from 'ws';
// RFC 6455 magic GUID — identical for every WebSocket server on earth.
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const SUPPORTED_SUBPROTOCOLS = ['state-sync-v2', 'state-sync-v1'];
// Reproduce the server's accept-token math for logging/debugging.
function computeAccept(secWebSocketKey: string): string {
return createHash('sha1')
.update(secWebSocketKey + WS_GUID) // concat client key with the fixed GUID
.digest('base64'); // SHA-1 digest, then base64 — this is Sec-WebSocket-Accept
}
const server = createServer();
// noServer: we own the upgrade so we can inspect headers before accepting.
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const key = req.headers['sec-websocket-key'];
const version = req.headers['sec-websocket-version'];
// A valid handshake MUST carry a key and announce protocol version 13.
if (!key || version !== '13') {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); // reject before upgrade
socket.destroy();
return;
}
// Pick the first subprotocol both sides support, or undefined for none.
const offered = (req.headers['sec-websocket-protocol'] ?? '')
.split(',').map((p) => p.trim());
const chosen = SUPPORTED_SUBPROTOCOLS.find((p) => offered.includes(p));
console.log('handshake', { key, accept: computeAccept(key), chosen });
// ws writes the 101 + correct Sec-WebSocket-Accept; we only pick the protocol.
wss.handleUpgrade(req, socket, head, (client) => {
wss.emit('connection', client, req);
});
});
wss.on('connection', (client, req) => {
// From here the socket carries WebSocket frames, not HTTP.
client.send(JSON.stringify({ type: 'hello', protocol: client.protocol }));
});
server.listen(8080);
On the client the handshake is implicit — the browser builds the Upgrade request for you. What you control is the subprotocol list (the second argument) and how you react once the negotiated protocol comes back:
// The browser generates Sec-WebSocket-Key and sends the Upgrade request itself.
const ws = new WebSocket('wss://api.example.com/sync', ['state-sync-v2', 'state-sync-v1']);
ws.addEventListener('open', () => {
// ws.protocol is only populated AFTER a successful 101 handshake.
if (!ws.protocol) {
// Server accepted the connection but agreed to no shared subprotocol.
console.error('No common subprotocol; closing.');
ws.close(1002, 'subprotocol mismatch'); // 1002 = protocol error
return;
}
console.log('Negotiated:', ws.protocol);
});
ws.addEventListener('close', (event) => {
// A failed handshake fires close with code 1006 and wasClean === false.
if (event.code === 1006) console.warn('Handshake never completed (1006).');
});
Configuration reference #
| Parameter | Type | Default | Production value | Notes |
|---|---|---|---|---|
Sec-WebSocket-Version |
header | 13 |
13 |
Only 13 is valid per RFC 6455; reject anything else with 400. |
Sec-WebSocket-Key |
header (16 random bytes, base64) | generated by browser | generated | Never reuse or validate as auth — it is a handshake nonce, not a token. |
Sec-WebSocket-Protocol |
header (CSV) | none | explicit allow-list | Server echoes exactly one value or omits it; never echo an unoffered protocol. |
handshakeTimeout (ws) |
ms | none | 10000 |
Cap how long a half-open upgrade may sit before the socket is destroyed. |
maxPayload (ws) |
bytes | 104857600 (100 MiB) |
1048576 |
Set per app to bound the first post-handshake frame and resist abuse. |
perMessageDeflate |
bool / object | false |
false unless measured |
Compression extension adds CPU and memory per connection; enable deliberately. |
proxy_read_timeout (nginx) |
seconds | 60 |
3600+ |
Too low and the proxy kills idle upgraded sockets mid-stream. |
Edge cases & gotchas #
- Proxies that strip
Upgrade. HTTP/1.1 listsUpgradeas a hop-by-hop header, so any intermediary that does not explicitly forward it will silently turn your upgrade into a normal200 OK. The browser then reports1006with no detail. This is the single most common handshake failure and is fixed at the proxy, not the app. - Echoing an unoffered subprotocol. If the client offers
state-sync-v2and the server responds withSec-WebSocket-Protocol: state-sync-v3, RFC 6455 requires the client to fail the connection. Always intersect the client’s offered list with your supported set and respond with a member of that intersection or nothing at all. - HTTP/2 and HTTP/3 break the classic upgrade. The
Upgrademechanism is HTTP/1.1-only. Under HTTP/2 the equivalent is the:protocolextended CONNECT (RFC 8441), which many proxies disable by default. If your CDN forces HTTP/2 to the origin, theGET-with-Upgradehandshake never arrives. Sec-WebSocket-Keyis not authentication. The key is a fixed-format nonce; any base64 of 16 bytes passes. Do not gate authorization on it. Authenticate during the upgrade via a cookie,Authorizationheader, or a token in the query string, validated in theupgradehandler before you callhandleUpgrade.
Verification #
Drive the handshake by hand with curl to see the raw bytes both directions — this isolates the server from the browser:
# -i prints response headers; --http1.1 forces the upgrade-capable version.
curl -i --http1.1 --no-buffer \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Protocol: state-sync-v2" \
https://api.example.com/sync
A healthy server returns HTTP/1.1 101 Switching Protocols plus Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= (the canonical accept for that sample key) and Sec-WebSocket-Protocol: state-sync-v2. Anything other than 101 means the upgrade was rejected or rewritten in transit. Then confirm the live path in the browser:
- In DevTools, open Network → WS → Headers and verify the request shows
Status Code: 101and the response carriesSec-WebSocket-Accept. A200or426here means a proxy intercepted the upgrade. - Check the Messages sub-tab — frames appearing there confirm the socket switched off HTTP successfully.
- On the server, assert the upgrade count:
ss -tnp | grep :8080 | wc -lshould track your active connection metric, not your request rate.
FAQ #
Why does my WebSocket connection fail with no error code? #
The browser collapses almost every handshake failure into close code 1006 with wasClean: false and prints connection failed to the console — there is no exposed HTTP status. Reproduce the handshake with the curl command above to recover the real status: a 200 means a proxy ate the Upgrade header, a 4xx/5xx means your server or auth rejected it, and only 101 means the handshake actually completed.
What is the Sec-WebSocket-Key / Sec-WebSocket-Accept exchange for? #
It proves the responder genuinely speaks WebSocket rather than being a cache or proxy replaying a canned 101. The server appends the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 to the client’s key, SHA-1 hashes it, base64-encodes the result, and returns it. It is a protocol sanity check, not a security or authentication mechanism.
Does the handshake work behind a reverse proxy or load balancer? #
Yes, but only if the proxy forwards the Upgrade and Connection headers and uses HTTP/1.1 to the origin. nginx, HAProxy, and AWS ALB all support this with explicit configuration; the defaults frequently do not. See configuring nginx for WebSocket upgrades for the exact directives.
How do I authenticate during the handshake? #
Validate credentials inside the upgrade event handler before calling handleUpgrade. Read a session cookie, Authorization header, or token, and respond with 401/403 directly to the raw socket if it fails. This keeps unauthorized clients from ever reaching frame processing — the Sec-WebSocket-Key itself carries no identity.
What changes for Socket.IO versus raw ws? #
Socket.IO performs the same RFC 6455 upgrade underneath, but first runs its own HTTP-polling handshake (the Engine.IO ?EIO=4&transport=polling request) and only then upgrades to WebSocket. So with Socket.IO you debug two handshakes; with raw ws you debug exactly the one described here.
Related #
- WebSocket vs SSE vs WebRTC Comparison — decide whether the upgrade handshake is even the transport you want.
- Configuring nginx for WebSocket Upgrades — the proxy directives that keep the
Upgradeheader intact. - Browser Compatibility & Polyfills — fallbacks when a network refuses the upgrade entirely.
- Security & TLS Configuration — terminating
wss://and validatingOriginon the upgrade.