Avoiding WebSocket reconnect loops on auth failure #

Your handshake error rate jumps to thousands per second overnight, yet no new users arrived. Logs show the same few hundred client IDs connecting, failing authentication, and connecting again every half second. A token signing key was rotated, or a batch of sessions expired together, and every affected client is running a reconnect loop that can never succeed: it retries with the same expired token, the server rejects it, and the backoff never grows because each attempt “connected” far enough to reset it. This page shows how to make authentication failures stop the loop, refresh credentials first, and only then try again.

Root cause #

Reconnect logic is usually written for one failure class — the network or the server went away — where retrying with the same inputs eventually works. Authentication failures are a different class: retrying with the same token will fail forever. The loop forms when the client cannot tell the two apart. That happens for three reasons.

First, the browser hides the reason a handshake failed. If your server rejects the upgrade with an HTTP 401, the browser fires error and then close with code 1006 and no status code — indistinguishable from a network failure. Second, many clients reset their backoff counter on open, so a server that accepts the upgrade and then immediately closes with an auth error causes a reconnect at the base delay every time. Third, the token is read once at startup and reused on every attempt, so even a client that does refresh on a timer keeps sending the stale value in its reconnect path.

The loop and the exit A client connects with a stale token, is accepted then closed with an auth error, and loops back at the base delay unless the close is classified as an authentication failure that routes to a token refresh. The loop and the exit Connecting stale token attached Open backoff reset to base Closed 4001 treated as retryable Refreshing new token first upgrade 101 auth rejected retry at base delay classified as auth The loop exists because the retry edge ignores why the socket closed
Classify the close before choosing the next state.

Resolution #

Fix it on both sides. The server should reject authentication in a way the client can read: accept the upgrade and close immediately with an application close code in the 4000–4999 range and a short reason, rather than returning an HTTP error the browser hides. (If you validate before the upgrade — which is better for resource use, as described in validating JWT on the WebSocket upgrade — have the client obtain a fresh token before every attempt so a 1006 after a failed handshake is recoverable.) The client then classifies close codes into retry classes and handles each one differently.

const CLOSE_AUTH_EXPIRED = 4001;   // token expired: refresh and retry
const CLOSE_AUTH_REVOKED = 4003; // session revoked or forbidden: stop, sign out
const CLOSE_POLICY = 1008; // generic policy violation from the server
const MAX_AUTH_REFRESHES = 2; // refresh attempts before we give up
const BASE_DELAY_MS = 500;
const MAX_DELAY_MS = 30_000;
const STABLE_AFTER_MS = 10_000; // only a connection that lasts this long resets backoff

type Decision = { action: 'retry' | 'refresh' | 'stop'; reason: string };

function classify(code: number): Decision {
if (code === CLOSE_AUTH_EXPIRED) return { action: 'refresh', reason: 'token_expired' };
if (code === CLOSE_AUTH_REVOKED || code === CLOSE_POLICY) return { action: 'stop', reason: 'forbidden' };
if (code === 1000) return { action: 'stop', reason: 'normal' };
return { action: 'retry', reason: `close_${code}` }; // 1001, 1006, 1011, 1012, 1013 …
}

export class AuthAwareSocket {
private attempt = 0;
private authRefreshes = 0;
private openedAt = 0;

constructor(
private url: string,
private getToken: (force: boolean) => Promise<string>, // force=true bypasses the cache
private onFatal: (reason: string) => void,
) {}

async connect(forceToken = false) {
const token = await this.getToken(forceToken); // fetched per attempt, never cached here
const ws = new WebSocket(`${this.url}?ticket=${encodeURIComponent(token)}`);
ws.onopen = () => { this.openedAt = Date.now(); };
ws.onclose = (e) => this.onClose(e.code);
}

private onClose(code: number) {
// A connection that died quickly does not count as success.
if (this.openedAt && Date.now() - this.openedAt >= STABLE_AFTER_MS) {
this.attempt = 0;
this.authRefreshes = 0;
}
this.openedAt = 0;
const d = classify(code);

if (d.action === 'stop') return this.onFatal(d.reason);
if (d.action === 'refresh') {
if (++this.authRefreshes > MAX_AUTH_REFRESHES) return this.onFatal('refresh_exhausted');
return void this.connect(true); // new token, no delay needed
}
const cap = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** this.attempt++);
setTimeout(() => void this.connect(false), Math.random() * cap); // full jitter
}
}

The STABLE_AFTER_MS rule is the quiet hero. Resetting backoff on open is what turns a server-side close into a tight loop; resetting only after a connection has proven itself stable means any close-immediately failure, auth or otherwise, still climbs the backoff curve. The full jitter schedule itself is explained in exponential backoff with jitter.

Backoff that keeps climbing when opens are short-lived Reconnect delay windows for 8 attempts computed from base 500 ms doubling to a 30000 ms cap; each attempt sleeps a uniform random value inside its bar. Backoff that keeps climbing when opens are short-lived base 500 ms, cap 30 s — bar spans the random range, red line is the ceiling 0s 10s 20s 30s try 1 try 2 try 3 try 4 try 5 try 6 try 7 try 8
Because a connection that closes within 10 s never resets the attempt counter, a persistent failure backs off to the 30 s cap instead of retrying at 500 ms forever.

Verification #

Reproduce the loop before fixing it, so you can see the fix work. Issue a token with a 30-second lifetime, connect, and let it expire while the socket is closed. With the old client, the server log shows one rejected attempt per base delay, indefinitely. With the new client, you should see exactly one 4001 close, one token refresh request, and a successful reconnect.

Server-side, track rejected handshakes per client identifier over a sliding window. A healthy fleet has a low, flat rate; a loop shows the same identifiers repeating at a fixed period:

# Top clients by auth rejections in the last 5 minutes (structured JSON logs)
jq -r 'select(.event=="ws_auth_rejected") | .client_id' app.log \
| sort | uniq -c | sort -rn | head -10

Any client with dozens of rejections in five minutes is looping. As a backstop, the server can refuse to even validate tokens for an identifier that has failed more than a threshold within a window, closing with 4003 so a fixed client gives up and a broken one at least stops costing you JWT verification.

Close code to client action A mapping of WebSocket close codes to client actions, backoff behaviour and what the user sees. Close code to client action Action Backoff User sees 1001 / 1006 / 1011 retry jittered, growing reconnecting banner 1013 try again later retry start at larger base reconnecting banner 4001 token expired refresh then retry none for first try nothing 4003 / 1008 stop none sign-in prompt Only the first row is a network problem; treating every row like it is what builds the loop
Four classes of close, four different reactions.

Operational checklist #

FAQ #

Why does my client see 1006 instead of my 401? #

The WebSocket API deliberately does not expose HTTP status codes from a failed handshake, partly to stop pages probing internal services. Any rejected upgrade surfaces as error followed by close with 1006. If the client needs to know the reason, accept the upgrade and close with an application code, or check the token’s expiry client-side before connecting.

Is it safe to accept the upgrade before checking the token? #

It costs a little more — the connection is fully established before you reject it — but for a small number of rejections that is acceptable, and it gives the client a readable reason. Under attack or at very high rejection rates, validate before upgrading and rely on the client refreshing its token per attempt instead.

Should the server close existing sockets when a token expires? #

If the token encodes authorization that must be re-checked, yes: close with 4001 at expiry so the client reconnects with a fresh token. Alternatively, let the client send a refreshed token over the open socket, as shown in rotating WebSocket tokens without dropping connections.

What about Socket.IO’s built-in reconnection? #

Socket.IO retries on its own schedule and resets on successful connect, so the same loop can form. Hook the connect_error event, inspect the error your middleware returned, and stop reconnection or update the auth payload before the next attempt.

Back to Auto-Reconnection Strategies.