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:// (plain ws:// is fine on localhost, but every production handshake rides on TLS).
  • A reverse proxy that passes Upgrade/Connection headers 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 ws package, or any server library whose handshake hooks you can inspect.
  • curl and 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.

WebSocket upgrade handshake sequence Client sends an HTTP GET Upgrade with Sec-WebSocket-Key; server hashes it with the GUID, returns 101 Switching Protocols and Sec-WebSocket-Accept; both then exchange WebSocket frames. Client (browser) Server (ws) GET /sync HTTP/1.1 + Upgrade Sec-WebSocket-Key: dGhlIHNhbQ== SHA-1(key + GUID) then base64 101 Switching Protocols Sec-WebSocket-Accept: s3pPLM… Full-duplex WebSocket frames same TCP socket, no more HTTP

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 lists Upgrade as a hop-by-hop header, so any intermediary that does not explicitly forward it will silently turn your upgrade into a normal 200 OK. The browser then reports 1006 with 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-v2 and the server responds with Sec-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 Upgrade mechanism is HTTP/1.1-only. Under HTTP/2 the equivalent is the :protocol extended CONNECT (RFC 8441), which many proxies disable by default. If your CDN forces HTTP/2 to the origin, the GET-with-Upgrade handshake never arrives.
  • Sec-WebSocket-Key is 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, Authorization header, or a token in the query string, validated in the upgrade handler before you call handleUpgrade.

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: 101 and the response carries Sec-WebSocket-Accept. A 200 or 426 here 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 -l should 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.

Back to Real-Time Protocol Selection & Architecture