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.

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.

Status code to root cause A lookup table mapping the HTTP status returned instead of 101 to its underlying cause and the layer to inspect. Status code to root cause Meaning Where to look 200 + HTML never reached the handler proxy upgrade block 400 headers rejected stripped Connection 401 / 403 auth or origin denied your own logs 404 path did not match proxy rewrite 426 upgrade required client sent no upgrade headers 502 / 504 upstream unreachable port, health check 101 then close app rejected it the close code The last row is a different kind of failure — the handshake worked and the application ended the connection
Seven outcomes, and the status identifies which one you have before reading a line of code.

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.

Isolating the hop that breaks it Running the same probe against the application, the proxy and the public edge in turn identifies the first hop that fails to return 101. Isolating the hop that breaks it curl Edge Proxy App probe the app directly — expect 101 probe via proxy — 200 means no upgrade block probe via edge — 403 means an edge rule 101 + Sec-WebSocket-Accept Work outwards from the application — the first hop that breaks owns the bug
Three probes localise almost any handshake failure to one configuration file.

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.

Back to Real-Time Protocol Selection & Architecture