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.
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.
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.
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.
Related #
- Protocol Handshake Mechanics — the full upgrade sequence and what each header does.
- WebSocket Subprotocol Negotiation — the one handshake header that fails silently rather than loudly.
- Configuring nginx for WebSocket Upgrades — the proxy configuration behind most
200responses. - HAProxy WebSocket Load Balancing Configuration — the same problem in HAProxy, plus tunnel timeouts.
- WebSocket Close Codes Explained — for when the handshake works and the close does not.
Back to Protocol Handshake Mechanics