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.
When the handshake fails #
The browser reports every upgrade failure identically: WebSocket connection to '…' failed, with no status code, no body and no indication whether the request reached your server. That opacity is deliberate — exposing a cross-origin response would leak information — and it is why every real diagnosis starts outside the console.
The handshake is an ordinary HTTP request, so curl sends exactly what the browser sends and prints exactly what came back. The status that arrives instead of 101 names the layer at fault before you read any code.
200 with a body means the request never reached the WebSocket handler: a proxy without an upgrade block, a framework route that matched first, or a static file server answering it as an ordinary request. 400 means the server saw the upgrade and rejected the headers — a missing version, a malformed key, or a stripped Connection. 403 is your own origin or authentication check, and the reason is in your logs even though the browser will not show it. 404 means a path rewrite somewhere. 502 or 504 means the proxy reached your upstream and got nothing usable.
There is a sixth outcome that is different in kind: a successful 101 followed by an immediate close. That is not a handshake failure at all — the connection was established and the application closed it — and the close code says why.
The isolation technique is to run the same probe against each hop in turn: the application port directly, then the local proxy, then the public edge. The first hop that stops returning 101 owns the bug, which narrows “staging is broken” to a single configuration file in about thirty seconds.
One header deserves special mention because it causes a 400 that makes no sense. Connection is a comma-separated list, and a proxy may legitimately send Connection: keep-alive, Upgrade. Code that compares the whole value to "Upgrade" rejects perfectly valid requests; parse it into tokens and check for membership.
Logging the rejection server-side is what stops the next person repeating all of this. Record the URL, the origin and the specific reason before writing the response, because the browser will surface none of it to the user or to your support team.
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.
Why does the browser not show me the status code? #
For security. The WebSocket API deliberately reports a generic failure so that a page cannot learn anything about a cross-origin response it would not otherwise be allowed to read. The information still exists — the Network panel shows the full request and response headers even for a failed upgrade, and curl shows everything — so the constraint is on the JavaScript API rather than on you.
What is Sec-WebSocket-Accept for? #
It proves the server understood the WebSocket handshake rather than merely returning 101 by accident. The server concatenates the client’s key with a fixed GUID from the specification, takes the SHA-1, and base64-encodes it; a client that receives a different value must fail the connection. In practice it also serves as a useful diagnostic: a wrong value means something rewrote the key in flight.
Can an upgrade fail because of HTTP/2? #
Yes, subtly. The classic upgrade is HTTP/1.1 only; HTTP/2 uses extended CONNECT instead, and not every intermediary implements it. A CDN that negotiates h2 to your origin without extended CONNECT support breaks upgrades in ways that look like routing bugs, which is why a diagnostic probe should force HTTP/1.1 explicitly.
Does the handshake cost enough to optimise? #
It costs three round trips before any application data flows — one for TCP, one or two for TLS depending on version and resumption, and the upgrade itself rides on the last. On a nearby connection that is tens of milliseconds; across an ocean it is most of a second, paid again on every reconnect. That arithmetic is why regional routing and session resumption matter more for WebSockets than for ordinary requests, and why an aggressive reconnect policy is expensive in a way that is easy to overlook.
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.