workerd error: websocket closed before handshake, origin rejected the upgrade
Fixes Workers websocket proxies failing when the origin rejects the upgrade handshake. Use when the worker forwards websocket connections and the origin closes them pre-handshake. Not for workerd-side websocket limits, client disconnects, or post-handshake message failures.
TL;DR
The worker proxied the websocket upgrade to the origin, and the origin said no: wrong path, missing auth header, or the origin does not speak websocket on that endpoint. The fix is on the origin side or in what the worker forwards. Verify the origin accepts a direct websocket, then make the worker forward the exact handshake the origin expects.
The query
workerd error: websocket closed before handshake, origin rejected the upgradeUse this when
- Websocket connections through the worker die during the upgrade.
- Direct-to-origin websockets work but worker-proxied ones fail.
- The failure started after an origin deploy or header change.
Not for
- 'WebSocket connection failed before handshake completed' on the workerd side (runtime issue).
- Clients disconnecting after a successful upgrade (application problem).
- Message size limit errors mid-connection (separate fix).
Steps
Step 1: Test the origin directly
Open a websocket from a test client straight to the origin, bypassing the worker.
Expected result: if direct fails too, the origin is the problem; fix it there. If direct works, the worker is altering the handshake.
Step 2: Compare forwarded headers
Log the upgrade request headers at the origin (or echo them from a debug endpoint): the worker must forward the Upgrade, Connection, and Sec-WebSocket-Key headers untouched, plus any auth the origin requires.
Expected result: you spot the dropped or rewritten header. Workers that rebuild the request instead of forwarding it often lose the websocket headers.
Step 3: Forward, do not reconstruct
const url = new URL(request.url);
url.hostname = "origin-internal.[domain]";
const upstream = new Request(url, request); // preserves method and headers
return fetch(upstream);Expected result: the handshake reaches the origin byte-identical. The origin accepts it exactly as it accepts direct connections.
Step 4: Check the origin path and protocol
The origin must serve websockets on the exact path and scheme the worker targets. A path that serves HTTP but not WS, or an http target for a wss expectation, gets rejected.
Expected result: scheme and path match what the origin's websocket server listens on.
Step 5: Verify end to end through the worker
Expected result: the full client-to-worker-to-origin upgrade completes and messages flow both ways.
Why it happens
A websocket upgrade is a precise HTTP handshake. Proxies that 'helpfully' normalize requests break it: dropping the Connection header, changing the path, or terminating TLS differently than the origin expects. The origin cannot distinguish a mangled proxy handshake from a bad client, so it closes the connection.
Edge cases
- Some origins require the original Host header. Forward it explicitly when the internal hostname differs.
- Durable Objects offer first-class websocket handling; for persistent connections, terminating at a DO instead of proxying to an origin removes the handshake fragility.
- Idle timeouts differ between the worker edge and the origin. A handshake that succeeds can still drop later on timeout mismatch.
- The 128-connection-ish practical limits on some paths mean load tests fail differently from single-connection tests.
Provenance
Resolved from the public thread: https://vectle.com/posts/pstJHpYLB1ClzOZbfZWAdLzg