Debugging WebSocket Handshake Failures #

WebSocket connection to 'wss://api.example.com/ws' failed — and that is all the browser will tell you. No status code in the console, no response body, no indication whether the request reached your server at all. The connection worked yesterday, works locally, and fails in staging.

The handshake is an ordinary HTTP request, which means it can be inspected like one. This page is the decision procedure: what each status code means, which header is missing, and the two commands that answer the question in under a minute.

Root cause #

An upgrade succeeds only when a specific set of conditions all hold. The client sends GET with Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Version: 13 and a random Sec-WebSocket-Key. The server must reply 101 Switching Protocols with Upgrade: websocket, Connection: Upgrade, and Sec-WebSocket-Accept computed as the base64 SHA-1 of the key concatenated with the RFC’s fixed GUID.

Anything else — any other status, any missing header, any intermediary that rewrote the request — and the browser reports the same opaque failure. What actually went wrong is almost always identifiable from the status code that came back instead of the 101:

200 OK with a body. The request never reached the WebSocket handler. Something upstream — a proxy without an upgrade block, a framework route that matched first, a static file server — answered it as a normal request.

400 Bad Request. The server received the upgrade but rejected the headers: a missing Sec-WebSocket-Version, a malformed key, or a proxy that stripped Connection.

403 Forbidden. An origin check or authentication failed. This is your own code, and the reason is in your logs even though the browser will not show it.

404 Not Found. The path is wrong, or a proxy rewrote it — /api/ws becoming /ws and not matching, or the reverse.

502 / 504. The proxy reached your upstream and got nothing usable: the app is down, listening on a different port, or timed out during the upgrade.

Status code to root cause A lookup table mapping the HTTP status returned instead of 101 Switching Protocols to its underlying cause and the configuration or code to inspect. Status code to root cause What it means Where to look 200 + HTML never reached the WS handler proxy upgrade block, route order 400 headers rejected stripped Connection / Version 401 / 403 auth or origin denied your verifyClient and app logs 404 path did not match proxy path rewrite 426 upgrade required client sent no upgrade headers 502 / 504 upstream unreachable port, health check, timeout 101 then instant close handshake fine, app rejected close code in the frame The last row is different in kind: the handshake succeeded and the application closed the connection immediately, so read the close code
Seven outcomes, and the status code identifies which one you have before you read a single line of code.

Resolution #

Bypass the browser. curl sends the exact handshake and prints the exact response, including the status the console hid from you.

# The canonical handshake probe. The key is the RFC's own example value, so the
# expected Sec-WebSocket-Accept is always s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
curl -i -N --http1.1 \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Origin: https://app.example.com" \
https://api.example.com/ws

A healthy response is unmistakable:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

If that Sec-WebSocket-Accept value differs, the server computed it wrong or something rewrote the key in flight. If the status is anything but 101, the table above tells you where to look.

The second command isolates where the failure happens. Run the same probe against each hop in turn — the public edge, then the proxy directly, then the application port:

curl -i -N --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://127.0.0.1:8080/ws # the app itself

curl -i -N --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://127.0.0.1:80/ws # through the local proxy

The first hop that stops returning 101 is the one that broke it. This narrows a “staging is broken” report to a single configuration file in about thirty seconds.

On the server, log the rejection rather than making the next person repeat this:

import { WebSocketServer } from 'ws';
import { createServer } from 'http';

const server = createServer();
const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', (req, socket, head) => {
const fail = (status: number, reason: string) => {
// Log BEFORE responding: the browser will never surface this to the user.
console.warn({ url: req.url, origin: req.headers.origin, status, reason }, 'upgrade rejected');
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
socket.destroy();
};

// Header presence, checked explicitly so each failure has its own reason.
const connection = String(req.headers.connection ?? '').toLowerCase();
if (!connection.split(',').some((t) => t.trim() === 'upgrade')) return fail(400, 'Bad Request');
if (String(req.headers.upgrade ?? '').toLowerCase() !== 'websocket') return fail(400, 'Bad Request');
if (req.headers['sec-websocket-version'] !== '13') return fail(426, 'Upgrade Required');

if (!isAllowedOrigin(req.headers.origin)) return fail(403, 'Forbidden');

wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});

server.listen(8080);

Checking the Connection header token-by-token matters: it 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 — and it is one of the most common causes of a 400 that makes no sense.

Isolating the hop that breaks the upgrade Running the same handshake probe against the application, then the proxy, then the public edge identifies the first hop that fails to return 101 Switching Protocols. Isolating the hop that breaks the upgrade curl Edge / CDN Proxy App probe 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 inwards to outwards: the first hop that stops returning 101 owns the bug
Three probes localise almost any handshake failure to one configuration file.

Verification #

Once it works, verify it stays working — handshake breakage is nearly always a configuration regression, not a code one.

# A CI smoke check that fails loudly if the upgrade stops working
status=$(curl -s -o /dev/null -w '%{http_code}' -N --http1.1 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://api.example.com/ws)

[ "$status" = "101" ] || { echo "handshake broken: $status"; exit 1; }

In the browser, the Network panel filtered to WS shows the handshake request with its full request and response headers even when the connection failed — the console message is a summary, not the data. Check three things there: that Sec-WebSocket-Version: 13 was sent, that Origin is what your allow-list expects, and that the response carries all three required headers.

The upgrade is short enough to hold in your head, and knowing which side owns each step is most of the diagnosis.

The upgrade, step by step, and who owns each The handshake proceeds through client-supplied headers, proxy and framework route matching, application origin and auth checks, and finally the server's 101 response with the computed accept digest. The upgrade, step by step, and who owns each GET with Upgrade + Connection client — omitted or rewritten gives 400 or 426 client Sec-WebSocket-Key + Version 13 client — a stripped key gives 400 client Route matching proxy and framework — the usual source of 200 or 404 infra Origin and auth check your code — gives 401 or 403, logged only by you app 101 + Sec-WebSocket-Accept server — a wrong digest fails the client silently server Only the middle band is shared between teams, which is why it is where most handshake bugs live
Five steps, three owners. The status code you get back tells you which owner to talk to.

Operational checklist #

FAQ #

Why does the browser hide the status code? #

Deliberately, for security. Exposing the response of a cross-origin request that the page could not otherwise read would leak information, so the WebSocket API reports a generic failure. The data is still available to you in the Network panel and to curl, which is why every real diagnosis starts outside the console.

The handshake succeeds but the connection closes immediately — what now? #

That is a different failure and a better one, because the application is talking to you. Read the close code: 1008 is a policy rejection, 4401-style codes are your own, and 1006 means the socket was destroyed without a close frame. WebSocket close codes explained maps each one, and the server logs will have the corresponding reason.

Why does it work locally but not behind the proxy? #

Because locally there is no proxy to drop headers. The overwhelmingly common cause is a reverse proxy without an upgrade block, which answers the request as ordinary HTTP and returns 200. For nginx that is the proxy_set_header Upgrade and Connection pair covered in configuring nginx for WebSocket upgrades; for HAProxy it is the tunnel timeout and ACL setup.

Do I need to send an Origin header from a non-browser client? #

No — Origin is a browser concept and native clients generally omit it. That has a security consequence: origin checks protect browser users and do nothing against a scripted client, which is why authentication must never rely on Origin alone. See enforcing origin and CSRF checks on WebSockets for what each check actually buys.

Can the handshake fail because of HTTP/2? #

Yes, subtly. The classic upgrade mechanism is HTTP/1.1 only; HTTP/2 uses extended CONNECT (RFC 8441) instead, and not every intermediary supports it. If a CDN or proxy negotiates h2 to your origin and does not implement extended CONNECT, upgrades fail in ways that look like a routing bug. Forcing --http1.1 in the probe is what distinguishes that case, and it is also why the flag is in the command above.

Back to Protocol Handshake Mechanics