An HTTP request carries its credentials every time. A WebSocket carries them once, during the handshake, and after that the server keeps the verdict (“this socket is alice”) but not the evidence. If the token that justified the verdict expires, is revoked, or loses a permission, nothing about the open socket changes.
This article follows one token through the life of a connection, looks at what a browser lets you do at each step, and then compares three ways to renew it. All code was run in the environment listed at the end.
TL;DR
- After
101 Switching Protocolsno HTTP request is made, so nothing re-validates the token. The connection outlives the credential unless the server enforces a deadline itself. - A browser page cannot set headers on a WebSocket and cannot tell why a handshake failed (an HTTP 401 and a network outage both arrive as close code 1006). Where the token goes and how errors are reported are protocol decisions you must make.
- Three renewal strategies: close and reconnect, send a new token in-band, send it out-of-band over HTTP naming the connection. Whichever you pick, keep the same four rules: the server enforces the deadline, the subject cannot change, the scope cannot widen, the lifetime cannot shrink. Every failure must fall back to a plain reconnect.
- Expiry is the only kind of drift a timer can handle. Revocation and permission changes need either short lifetimes or a push from your own system.
The journey of one token
Station 1: the constructor has no place for a header
The WebSocket interface in the WHATWG specification is new WebSocket(url, protocols). There is no options argument, so a page cannot add an Authorization header (a non-browser client such as the Node ws library can). The token has to ride somewhere else:
| Where the token goes | Works in a browser | What it costs |
|---|---|---|
Authorization header | No | Only for non-browser clients. |
| Cookie | Yes, automatically | Ambient authority: unless SameSite keeps the cookie off cross-site requests, a page on another site can try to open the socket with the user’s cookies, and you should not rely on that alone. RFC 6455 §10.2 says servers that accept input only from certain sites should verify Origin and answer 403 for the others. |
| Query string | Yes | URIs are logged and displayed in many places (RFC 9110 §17.9). A token in the URL ends up in logs. |
Sec-WebSocket-Protocol | Yes | A convention, not a feature designed for secrets. The server must select one of the offered values, otherwise the browser fails the connection (see below). Values must be HTTP tokens, so use base64url. |
| First message after open | Yes | The server accepts a connection it cannot attribute yet. Close it if no valid auth message arrives within a short deadline. |
The Sec-WebSocket-Protocol row has a sharp edge. The WHATWG algorithm says that if the page offered subprotocols and the response does not name one, the connection fails. RFC 6455 §4.1 alone only requires failing when the server names a value the client did not offer; the browser is stricter. The check script at the end confirms this in Chrome.
Station 2: the handshake can say no, but the page cannot hear why
RFC 6455 §10.5 does not prescribe a client authentication mechanism: the server can use whatever a generic HTTP server can use. So rejecting a bad token with a plain 401 is legitimate.
What the page sees is another matter. The WHATWG spec lists a set of failures that a script must not be able to tell apart, among them “a server that did not complete the opening handshake”, because distinguishing them “would allow a script to probe the user’s local network”. All of them surface as error plus a close event with code 1006. In headless Chrome 154 I got exactly that for a handshake answered with 401, and for a server that ignored the offered subprotocol:
| Probe (headless Chrome 154) | opened | close code | reason |
|---|---|---|---|
Server selects bearer, then closes with 4401 after the open | yes | 4401 | token expired |
| Server offers no subprotocol back | no | 1006 | (empty) |
| Server answers the upgrade with HTTP 401 | no | 1006 | (empty) |
This is the first practical consequence: a rejected handshake and a network failure look the same to the page. A client that sees 1006 should treat it as “maybe my token is bad”: refresh the token once, retry once, and only then back off. And if you want the page to learn that the token expired, accept the connection and close it with an application code. RFC 6455 §7.4.2 reserves 4000–4999 for private use, which is where 4401 in this article comes from. (1006 itself is never sent on the wire; §7.4.1 reserves it for “closed abnormally”.)
Station 3: after the 101, the server remembers a verdict
Once the server answers 101, the connection is a byte stream with frames on it. No frame carries a credential. The server’s only record is whatever it stored at handshake time, usually something like socket.claims = { sub, scope, exp }.
Station 4: three kinds of drift
The token and the socket can drift apart in three ways, and they are not equally easy:
- Expiry. Known in advance. A timer on the server can handle it.
- Revocation. Unknown in advance. The server learns it only if something tells it or it looks.
- Permission change. Same: a role changes, a scope is narrowed, an account is suspended.
A renewal protocol mostly solves the first. For the other two, the best a renewal can do is bound the delay: the socket cannot outlive its current token, so the worst case is the remaining lifetime. If that is too long for you, shorten lifetimes or push a close from your own system (for example, close every socket belonging to a user when that user logs out).
Three ways to renew
The decision is shaped by one question: what does a disconnect cost you? Suppose the answer is “very little, a resume is cheap.” Then you want the simplest mechanism. If the answer is “a lot, a reconnect means re-subscribing to many things”, you want the connection to survive. A short decision record:
| A. Close and reconnect | B. In-band auth message | C. Out-of-band HTTP request | |
|---|---|---|---|
| Mechanism | Server closes at expiry (code 4401); client fetches a new token and connects again | Server sends reauth; client replies on the same socket with a new token | Client sends an ordinary authenticated HTTP request that names the live connection |
| Stream continuity | Broken; needs resume logic (cursors and snapshots) | Preserved | Preserved |
| Where the token is parsed | Handshake code you already have | The WebSocket message handler | Your existing HTTP auth stack |
| Routing | Any node | Lands on the right node by construction | Must reach the node that holds the socket (sticky routing or a pub/sub hop) |
| Ordering with application messages | n/a | Same connection, so ordered with everything else | Separate channel; can race with a close |
| Extra protocol surface | None | One message type | One endpoint |
| Failure behavior | Reconnect | Falls back to A | Falls back to A |
Pick A unless a disconnect is expensive. Pick B when you already have a message protocol and want the simplest preserved stream. Pick C when your authentication and rate-limiting live in the HTTP layer and you can route to the node holding the socket. In all three, A is the fallback: if any step of B or C fails, the server will close at expiry and the client will reconnect. That property is what makes renewal an optimization instead of a new way to break.
A runnable server with the four rules
Whichever channel carries the new token, the server needs the same four rules. They are small but each one blocks a real mistake:
- The server enforces the deadline. It asks for a new token shortly before expiry and closes at expiry if none arrived. It does not trust the client to come back.
- Same subject. A valid token for somebody else is not a valid renewal of this socket.
- Never widen the scope. Narrowing is applied immediately. Gaining rights requires a new handshake, so a renewal cannot silently escalate.
- Never shorten the lifetime. A stale token (valid, but older than the current one) is ignored.
Rules 2–4 are one function. Rule 1 is another:
// Renewal rules: same subject, never widen the scope, never shorten the lifetime.
function accept(ws, c) {
if (revoked.has(c.jti) || c.sub !== ws.claims.sub) return false;
if (!c.scope.every((s) => ws.claims.scope.includes(s))) return false; // more rights need a fresh handshake
ws.claims = { ...ws.claims, scope: c.scope, exp: Math.max(ws.claims.exp, c.exp) }; // narrow now; extend, never shorten
arm(ws);
return true;
}
// The server, not the client, enforces the deadline: ask for credentials shortly before, close at expiry.
function arm(ws) {
clearTimeout(ws.warn); clearTimeout(ws.kill);
const left = ws.claims.exp - Date.now();
ws.warn = setTimeout(() => ws.readyState === 1 && ws.send(JSON.stringify({ type: "reauth" })), Math.max(0, left - leadMs));
ws.kill = setTimeout(() => ws.close(CLOSE_EXPIRED, "token expired"), left);
}
The server prompts at exp - leadMs instead of leaving the schedule to the client. The client’s clock may be wrong; the server’s clock is the one that decides expiry. The client only has to answer when asked:
ws.on("message", async (raw) => {
const m = JSON.parse(raw);
if (m.type === "tick") stats.ticks++;
if (m.type === "reauth" && strategy === "in-band") ws.send(JSON.stringify({ type: "auth", token: await getToken() }));
if (m.type === "reauth" && strategy === "out-of-band")
await fetch(`http://127.0.0.1:${port}/renew/${id}`, { method: "POST", headers: { authorization: `Bearer ${await getToken()}` } });
});
Strategy B is the in-band branch; strategy C is the out-of-band branch (the server side is the /renew/ handler in the listing below); strategy A is the default close handler that reconnects. All three share the last line: whatever happens, if the socket closes, reconnect.
token.mjs: a toy signed token (illustration only)
// A toy signed token: base64url(JSON claims) + "." + HMAC. Illustration only; use a real JWT/PASETO library in production.
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
const SECRET = "demo-secret";
const b64 = (s) => Buffer.from(s).toString("base64url");
const sign = (p) => createHmac("sha256", SECRET).update(p).digest("base64url");
export const mint = (sub, ttlMs, scope = ["read"]) => {
const p = b64(JSON.stringify({ sub, scope, exp: Date.now() + ttlMs, jti: randomUUID() }));
return `${p}.${sign(p)}`;
};
export const verify = (token) => {
const [p, s] = String(token).split(".");
if (!p || !s || s.length !== sign(p).length || !timingSafeEqual(Buffer.from(s), Buffer.from(sign(p)))) return null;
const claims = JSON.parse(Buffer.from(p, "base64url"));
return claims.exp > Date.now() ? claims : null;
};
server.mjs: handshake, three strategies, deadline enforcement
// A WebSocket server that authenticates at the handshake and then keeps authorizing for the life of the connection.
import http from "node:http";
import { WebSocketServer } from "ws";
import { verify } from "./token.mjs";
export const CLOSE_EXPIRED = 4401, CLOSE_FORBIDDEN = 4403; // 4000-4999: private use (RFC 6455 section 7.4.2)
export function startServer({ leadMs = 150, revoked = new Set() } = {}) {
const conns = new Set();
const wss = new WebSocketServer({ noServer: true, handleProtocols: () => "bearer" });
const server = http.createServer(async (req, res) => {
// Strategy C (out of band): an ordinary authenticated HTTP request that names a live connection.
if (req.method === "POST" && req.url.startsWith("/renew/")) {
const claims = verify((req.headers.authorization ?? "").replace(/^Bearer /, ""));
const conn = [...conns].find((c) => c.id === req.url.slice(7));
if (!claims || !conn) { res.writeHead(401).end(); return; }
res.writeHead(accept(conn, claims) ? 204 : 403).end();
return;
}
res.writeHead(404).end();
});
server.on("upgrade", (req, socket, head) => {
const offered = String(req.headers["sec-websocket-protocol"] ?? "").split(/,\s*/); // ["bearer", "<token>"]
const claims = verify(offered[1]);
if (!claims || revoked.has(claims.jti)) { socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"); return; }
wss.handleUpgrade(req, socket, head, (ws) => {
ws.id = new URL(req.url, "http://x").searchParams.get("id"); ws.claims = claims; conns.add(ws);
ws.on("close", () => { conns.delete(ws); clearTimeout(ws.warn); clearTimeout(ws.kill); });
ws.on("message", (raw) => { // Strategy B (in band)
let m; try { m = JSON.parse(raw); } catch { return ws.close(1008); }
if (m.type === "auth") { const c = verify(m.token); if (!c || !accept(ws, c)) ws.close(CLOSE_FORBIDDEN, "renewal rejected"); }
});
arm(ws);
wss.emit("ready", ws);
});
});
// Renewal rules: same subject, never widen the scope, never shorten the lifetime.
function accept(ws, c) {
if (revoked.has(c.jti) || c.sub !== ws.claims.sub) return false;
if (!c.scope.every((s) => ws.claims.scope.includes(s))) return false; // more rights need a fresh handshake
ws.claims = { ...ws.claims, scope: c.scope, exp: Math.max(ws.claims.exp, c.exp) }; // narrow now; extend, never shorten
arm(ws);
return true;
}
// The server, not the client, enforces the deadline: ask for credentials shortly before, close at expiry.
function arm(ws) {
clearTimeout(ws.warn); clearTimeout(ws.kill);
const left = ws.claims.exp - Date.now();
ws.warn = setTimeout(() => ws.readyState === 1 && ws.send(JSON.stringify({ type: "reauth" })), Math.max(0, left - leadMs));
ws.kill = setTimeout(() => ws.close(CLOSE_EXPIRED, "token expired"), left);
}
return { server, wss, conns };
}
demo.mjs: runs all three strategies with 500 ms tokens
// Three ways to keep a connection alive past its token. Run: npm i ws && node demo.mjs
import WebSocket from "ws";
import { mint } from "./token.mjs";
import { startServer, CLOSE_EXPIRED } from "./server.mjs";
const TTL = 500, RUN = 2600; // tiny lifetimes so that the demo takes seconds
const { server, wss } = startServer({ leadMs: 150 });
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
wss.on("ready", (ws) => { let n = 0; ws.tick = setInterval(() => ws.readyState === 1 && ws.send(JSON.stringify({ type: "tick", n: ++n })), 100); ws.on("close", () => clearInterval(ws.tick)); });
const getToken = async () => mint("alice", TTL); // stands in for your token endpoint
async function run(strategy) {
const stats = { connections: 0, closes: [], ticks: 0 };
let ws, stop = false, id = 0;
const open = async () => {
stats.connections++; id++;
ws = new WebSocket(`ws://127.0.0.1:${port}/?id=${id}`, ["bearer", await getToken()]);
ws.on("message", async (raw) => {
const m = JSON.parse(raw);
if (m.type === "tick") stats.ticks++;
if (m.type === "reauth" && strategy === "in-band") ws.send(JSON.stringify({ type: "auth", token: await getToken() }));
if (m.type === "reauth" && strategy === "out-of-band")
await fetch(`http://127.0.0.1:${port}/renew/${id}`, { method: "POST", headers: { authorization: `Bearer ${await getToken()}` } });
});
ws.on("close", (code) => { stats.closes.push(code); if (!stop) open(); }); // every strategy falls back to reconnect
ws.on("error", () => {});
};
await open();
await new Promise((r) => setTimeout(r, RUN));
stop = true; ws.close();
await new Promise((r) => setTimeout(r, 50));
return stats;
}
for (const s of ["reconnect", "in-band", "out-of-band"]) {
const { connections, closes, ticks } = await run(s);
console.log(`${s.padEnd(12)} connections=${connections} server closes with 4401=${closes.filter((c) => c === CLOSE_EXPIRED).length} ticks received=${ticks}`);
}
server.close(); server.closeAllConnections();
What it does
The demo uses 500 ms token lifetimes and runs each strategy for 2.6 seconds, so the effect is visible in seconds. Output from one run:
reconnect connections=6 server closes with 4401=5 ticks received=20
in-band connections=1 server closes with 4401=0 ticks received=25
out-of-band connections=1 server closes with 4401=0 ticks received=25
The numbers are from this toy setup and will differ on your machine; the shape is what matters. With reconnect, the server closed with 4401 five times and the client opened six connections. The in-band and out-of-band runs stayed on one connection. The reconnect run also received fewer tick messages: time spent between two connections is time spent not receiving. In a real system, whatever the server produced in that window is what a resume mechanism has to replay, which is why strategy A needs one.
Properties as tests
The properties above are each pinned by a test. Nine tests pass in the environment below.
reauth.test.mjs: one test per rule
import assert from "node:assert/strict";
import { test, before, after } from "node:test";
import WebSocket from "ws";
import { mint, verify } from "./token.mjs";
import { startServer, CLOSE_EXPIRED, CLOSE_FORBIDDEN } from "./server.mjs";
const revoked = new Set();
const { server, conns } = startServer({ leadMs: 100, revoked });
let port;
before(async () => { await new Promise((r) => server.listen(0, "127.0.0.1", r)); port = server.address().port; });
after(() => { server.close(); server.closeAllConnections(); });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const connect = (token) => new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/?id=${Math.random()}`, ["bearer", token]);
ws.closed = new Promise((r) => ws.on("close", (code) => r(code)));
ws.on("open", () => resolve(ws)); ws.on("error", reject);
});
test("without renewal the server closes with 4401 at expiry", async () => {
const ws = await connect(mint("alice", 300));
assert.equal(await ws.closed, CLOSE_EXPIRED);
});
test("a valid in-band renewal keeps the connection past the original expiry", async () => {
const ws = await connect(mint("alice", 300));
ws.send(JSON.stringify({ type: "auth", token: mint("alice", 2000) }));
await sleep(500);
assert.equal(ws.readyState, WebSocket.OPEN);
ws.close();
});
test("a token for another subject is refused", async () => {
const ws = await connect(mint("alice", 2000));
ws.send(JSON.stringify({ type: "auth", token: mint("bob", 4000) }));
assert.equal(await ws.closed, CLOSE_FORBIDDEN);
});
test("a renewal cannot widen the scope", async () => {
const ws = await connect(mint("alice", 2000, ["read"]));
ws.send(JSON.stringify({ type: "auth", token: mint("alice", 4000, ["read", "write"]) }));
assert.equal(await ws.closed, CLOSE_FORBIDDEN);
});
test("a narrower scope is applied immediately", async () => {
const ws = await connect(mint("alice", 2000, ["read", "write"]));
ws.send(JSON.stringify({ type: "auth", token: mint("alice", 100, ["read"]) })); // narrower and older
await sleep(100);
const server_side = [...conns].find((c) => c.claims.sub === "alice" && c.claims.scope.length === 1);
assert.ok(server_side, "the server now holds the narrower scope");
assert.equal(ws.readyState, WebSocket.OPEN);
ws.close();
});
test("a stale token cannot shorten the lifetime", async () => {
const ws = await connect(mint("alice", 2000));
ws.send(JSON.stringify({ type: "auth", token: mint("alice", 100) })); // valid, but older than the current expiry
await sleep(400);
assert.equal(ws.readyState, WebSocket.OPEN);
ws.close();
});
test("revocation does not cut off an open connection before it expires", async () => {
const old = mint("alice", 600);
const ws = await connect(old);
revoked.add(verify(old).jti); // revoke the token the connection was admitted with
await sleep(200);
assert.equal(ws.readyState, WebSocket.OPEN); // still open: nothing re-checks revocation by itself
assert.equal(await ws.closed, CLOSE_EXPIRED); // it ends at expiry, the worst-case revocation delay
});
test("a revoked token is refused as a renewal", async () => {
const ws = await connect(mint("alice", 2000));
const renewal = mint("alice", 4000); revoked.add(verify(renewal).jti);
ws.send(JSON.stringify({ type: "auth", token: renewal }));
assert.equal(await ws.closed, CLOSE_FORBIDDEN);
});
test("a revoked token cannot be admitted", async () => {
const t = mint("alice", 2000); revoked.add(verify(t).jti);
await assert.rejects(connect(t), /401/);
});
Two of them are worth reading as documentation:
a stale token cannot shorten the lifetime: the server accepts a valid token, but does not move the deadline backwards.revocation does not cut off an open connection before it expires: the test asserts the bad behavior on purpose. Revoking the token does nothing to the open socket until its expiry. This is the revocation delay you buy with every renewal strategy in this article, so write it down as a requirement instead of discovering it later.
Failure modes
| Failure | What happens | What to do |
|---|---|---|
Token endpoint is slow or down when reauth arrives | No renewal arrives; the server closes with 4401 at expiry | The client reconnects. Choose leadMs larger than the token endpoint’s timeout plus a round trip. |
| Client clock is wrong | If the client schedules renewal itself, it renews late or in a tight loop | Let the server prompt, as above. |
| Token revoked while the socket is open | The socket stays up until its expiry | Shorten lifetimes, or close sockets by subject from the system that performs the revocation. |
| Permission narrowed | Old scope remains on the socket | Apply the narrower scope at the next renewal; push a close if it cannot wait. |
| Renewal for another subject | Without a check, one user could extend another’s socket | Compare sub; close with 4403 (test: a token for another subject is refused). |
| Renewal widens the scope | Silent privilege escalation | Reject; require a new handshake (test: a renewal cannot widen the scope). |
| Old renewal replayed | Could shorten or reset the deadline | Ignore tokens that do not extend exp. |
| Token in the query string | Ends up in access logs and proxies | Prefer a subprotocol, a first message, or a cookie with an Origin check. |
| Cookie authentication | A cross-site page can open the socket with the user’s cookies unless SameSite stops it | Verify Origin in the handshake (RFC 6455 §10.2). Origin is a defence against browsers, not a substitute for authentication. |
| First-message authentication | An unauthenticated socket occupies resources | Close it if no valid auth arrives within a short deadline. |
| Handshake rejected with 401 | The page sees 1006, same as a network error | Refresh the token once and retry once before backing off. |
Which renewal, if any
- Use in-band or out-of-band renewal when sockets routinely outlive the token and a reconnect is expensive.
- Use plain reconnect when sockets are cheap to re-establish or you already have a resume path. It is the simplest and it is always the fallback.
- Do not build any renewal for connections that last for minutes. Authenticate at the handshake, let the socket end, and let the client reconnect with a fresh token.
- Do not treat renewal as revocation. If “log out everywhere” must take effect in seconds, you need a push path, not a longer renewal protocol.
Try it
Everything below was run on Node.js 20; 18 or newer should work. It takes about ten minutes.
mkdir reauth && cd reauth
npm init -y && npm i ws@8
# save token.mjs, server.mjs, demo.mjs, reauth.test.mjs and browser_check.mjs from the listings above
node demo.mjs # three strategies, compare the connection counts
node --test reauth.test.mjs # nine tests
node browser_check.mjs # needs Chrome or Chromium
To see what a real browser reports, run the check below. It starts a server with three endpoints (one that selects bearer and later closes with 4401, one that selects no subprotocol, one that answers the upgrade with 401), opens them from a headless Chrome page, and prints the code, reason and wasClean the page saw. Set CHROME=/path/to/chrome if google-chrome is not on your PATH.
browser_check.mjs: what does a page see when a handshake is rejected?
// What can a page's script see when a WebSocket handshake or connection is rejected?
// Run: npm i ws && node browser_check.mjs (needs Chrome/Chromium; set CHROME=/path if needed)
import http from "node:http";
import { spawn } from "node:child_process";
import { WebSocketServer } from "ws";
const page = `<script>
const results = {};
function probe(name, url, protocols) {
return new Promise((resolve) => {
const info = { opened: false, error: false, close: null };
const ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);
ws.onopen = () => { info.opened = true; info.protocol = ws.protocol; };
ws.onerror = () => { info.error = true; };
ws.onclose = (e) => { info.close = { code: e.code, reason: e.reason, wasClean: e.wasClean }; results[name] = info; resolve(); };
});
}
(async () => {
const base = "ws://" + location.host;
await probe("subprotocol echoed, then server closes 4401", base + "/ok", ["bearer", "tok"]);
await probe("subprotocol offered but not selected", base + "/noproto", ["bearer", "tok"]);
await probe("HTTP 401 at the upgrade", base + "/unauth");
await fetch("/report", { method: "POST", body: JSON.stringify(results, null, 1) });
})();
</script>`;
const http_ = http.createServer(async (req, res) => {
if (req.url === "/") { res.writeHead(200, { "content-type": "text/html" }); return res.end(page); }
if (req.url === "/report") {
let b = ""; for await (const c of req) b += c;
console.log(b); res.end("ok"); clearTimeout(guard); chrome.kill(); http_.close(); http_.closeAllConnections();
}
});
const wss = new WebSocketServer({ noServer: true, handleProtocols: (set, req) => (req.url === "/noproto" ? false : "bearer") });
http_.on("upgrade", (req, socket, head) => {
if (req.url === "/unauth") { socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; }
wss.handleUpgrade(req, socket, head, (ws) => setTimeout(() => ws.close(4401, "token expired"), 100));
});
await new Promise((r) => http_.listen(0, "127.0.0.1", r));
const guard = setTimeout(() => { console.log("timeout"); chrome.kill(); process.exit(1); }, 20000);
const chrome = spawn(process.env.CHROME ?? "google-chrome", [
"--headless=new", "--no-sandbox", "--disable-gpu", "--user-data-dir=./ws-check-profile",
`http://127.0.0.1:${http_.address().port}/`,
], { stdio: "ignore" });
Trusted for as long as it stays open
A WebSocket is authenticated at one moment and trusted for as long as it stays open. Three things decide whether that is acceptable: how long a socket can outlive its token (set by your deadline), how fast you need revocation (set by your lifetimes or your push path), and how expensive a reconnect is (which picks the renewal channel). Reconnect-at-expiry is always correct and sometimes enough; in-band and out-of-band renewal are optimizations on top of it, and they stay safe only while every failure still ends in a reconnect.
Environment and what was not tested
All code was run on Linux 6.12 (x86-64) with Node.js 20.19, ws 8.22 and headless Google Chrome 154. The browser behaviors in the table under Station 2 were observed in that Chrome version; the specification statements are quoted from the linked documents as of 2026-10-04. The token format is a toy HMAC construction for illustration. The demo uses tiny lifetimes, a single process and no TLS; none of the numbers are benchmarks. Multi-node routing for strategy C, proxies that close idle connections, and other browsers were not tested.