How to test websocket reconnect
Run a client that logs the delay it chose, stop the server, then start it again. node wsretry.mjs "ws://127.0.0.1:9483/echo" jitter prints close 1006, retries after 318, 942, 1242, 3295, 4996 and 5884 ms, and reconnects at 18945 ms. Key the loop to the error event as well as close.
Why check this
Run this before any release that changes the socket layer, before a deploy that restarts the service under live traffic, and after a proxy or timeout change. The reconnect path runs only when something has already gone wrong, so it is the code least likely to have been exercised before an incident.
Two failures are worth naming. A loop that never fires leaves the page looking connected and silent in fact, because the first attempt failed in a way the loop was not listening for. A loop on a fixed interval turns one server restart into a synchronised burst that stops the restarted server from coming up.
The check tells you which delays the client used and whether it recovers. It runs on loopback, so it holds no network failure modes: no DNS, no captive portal, no half-open socket.
Prerequisites
- Node 22 or later, whose global
WebSocketis the browser API. See MDN on WebSocket. npm i ws@8in an empty directory for the server half.- Two ports, one for the server and one with nothing listening. Check both with
netstat -ano | grep 9483first. - The target server,
wsprobe.mjs. You will stop and restart it by process id, which is what a deploy does.
// wsprobe.mjs - one target for every WebSocket lifecycle check. node wsprobe.mjs 9481
import { WebSocketServer } from 'ws';
const port = Number(process.argv[2] ?? 9481);
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const wss = new WebSocketServer({ port }, () => log('listening on', port));
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://127.0.0.1');
const arg = (k, d) => Number(url.searchParams.get(k) ?? d);
log('open', req.url);
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; log('pong from client'); });
ws.on('message', (d) => { log('recv', JSON.stringify(String(d))); if (url.pathname === '/echo') ws.send(String(d)); });
ws.on('close', (c, r) => log('server close event', c, JSON.stringify(String(r))));
if (url.pathname === '/close') {
setTimeout(() => { log('ws.close(', arg('code', 1000), ')'); ws.close(arg('code', 1000), url.searchParams.get('reason') ?? ''); }, arg('after', 300));
}
if (url.pathname === '/crash') {
setTimeout(() => { log('ws.terminate(), no close frame'); ws.terminate(); }, arg('after', 300));
}
if (url.pathname === '/ping') {
const t = setInterval(() => { log('ws.ping()'); ws.ping(); }, arg('every', 1000));
ws.on('close', () => clearInterval(t));
}
if (url.pathname === '/idle') {
const every = arg('every', 3000);
const t = setInterval(() => {
if (ws.isAlive === false) { log('no pong since last sweep, terminate'); clearInterval(t); return ws.terminate(); }
ws.isAlive = false; log('sweep: ws.ping()'); ws.ping();
}, every);
ws.on('close', () => clearInterval(t));
}
});
- An event probe,
openfail.mjs, for step 2.
// openfail.mjs - which events fire when the connection never opens?
const t0 = Date.now();
const at = (...a) => console.log(String(Date.now() - t0).padStart(6), 'ms', ...a);
const seen = [];
const ws = new WebSocket(process.argv[2]);
for (const e of ['open', 'message', 'error', 'close']) ws.addEventListener(e, (ev) => { seen.push(e); at('event:', e, e === 'close' ? `code=${ev.code} wasClean=${ev.wasClean}` : ''); });
setTimeout(() => { at('events in 3 s:', seen.join(', ') || '(none)'); at('readyState', ws.readyState, '(3 = CLOSED)'); process.exit(0); }, 3000);
- The client under test,
wsretry.mjs. It listens toerrorandclose, and asettledflag keeps one failure from scheduling two retries.
// wsretry.mjs - reconnect with backoff, logging the delay it actually used.
// node wsretry.mjs ws://127.0.0.1:9483/echo jitter
const [url, mode] = [process.argv[2], process.argv[3] ?? 'jitter'];
const t0 = Date.now();
const at = (...a) => console.log(String(Date.now() - t0).padStart(6), 'ms', ...a);
const delay = (n) => { const cap = Math.min(500 * 2 ** (n - 1), 8000); return mode === 'fixed' ? 1000 : Math.round(cap / 2 + Math.random() * cap / 2); };
let attempt = 0;
function connect() {
const ws = new WebSocket(url);
let settled = false;
const again = (why) => {
if (settled) return; settled = true; attempt += 1;
const w = delay(attempt);
at(`${why}; attempt ${attempt} failed, retry in ${w} ms`);
setTimeout(connect, w);
};
ws.addEventListener('open', () => { attempt = 0; at('open'); });
ws.addEventListener('error', () => again('error event'));
ws.addEventListener('close', (e) => again(`close ${e.code} wasClean=${e.wasClean}`));
}
connect();
setTimeout(() => process.exit(0), Number(process.argv[4] ?? 25000));
- A fleet,
herd.mjs, for steps 4 and 5.
// herd.mjs - when 20 clients lose one server, when do they come back?
// node herd.mjs ws://127.0.0.1:9483/echo fixed 20
const [url, mode, N] = [process.argv[2], process.argv[3], Number(process.argv[4] ?? 20)];
const fires = [];
let base = 0;
for (let i = 0; i < N; i += 1) {
const ws = new WebSocket(url);
ws.addEventListener('error', () => {});
ws.addEventListener('close', () => {
if (!base) base = Date.now();
setTimeout(() => {
fires.push(Date.now() - base);
new WebSocket(url).addEventListener('error', () => {});
}, mode === 'fixed' ? 1000 : Math.round(Math.random() * 1000));
});
}
setTimeout(() => {
const b = new Map();
for (const f of fires) { const k = Math.floor(f / 100) * 100; b.set(k, (b.get(k) ?? 0) + 1); }
console.log(`${mode}: ${fires.length} reconnect attempts, spread ${Math.min(...fires)}-${Math.max(...fires)} ms after the server went away`);
for (const k of [...b.keys()].sort((x, y) => x - y)) console.log(`${String(k).padStart(4)}-${k + 99} ms ${'#'.repeat(b.get(k))} ${b.get(k)}`);
process.exit(0);
}, 4000);
Steps
- Step 1.
Start the target in its own terminal and leave it running.
node wsprobe.mjs 948322:48:58.955 listening on 9483 - Step 2.
Find out which events a failed connection produces. Run the probe against a dead port, then against the live one.
for p in 9499 9483; do node openfail.mjs "ws://127.0.0.1:$p/echo"; done32 ms event: error 3040 ms events in 3 s: error 3040 ms readyState 0 (3 = CLOSED) 42 ms event: open 3035 ms events in 3 s: open 3035 ms readyState 1 (3 = CLOSED)A connection that never opened fired
errorand nothing else, and three seconds laterreadyStatewas still 0, CONNECTING. No close event, no CLOSED state. A reconnect loop written againstonclosealone stops here and never runs again. Chrome 152 does not behave this way: driven against the same dead port it firederrorthenclose code=1006 wasClean=falseand reachedreadyState 3, so the same loop passes in a browser and hangs under Node. - Step 3.
Run the client, stop the server from a second terminal, wait, then start it again. Find the process id with
netstat -ano | grep 9483and stop that id only.node wsretry.mjs "ws://127.0.0.1:9483/echo" jitter 2400033 ms open 2232 ms close 1006 wasClean=false; attempt 1 failed, retry in 318 ms 2552 ms error event; attempt 2 failed, retry in 942 ms 3507 ms error event; attempt 3 failed, retry in 1242 ms 4753 ms error event; attempt 4 failed, retry in 3295 ms 8056 ms error event; attempt 5 failed, retry in 4996 ms 13056 ms error event; attempt 6 failed, retry in 5884 ms 18945 ms openRead the left column of reasons. The first retry came from
close, because that socket had been open. Every later one came fromerror, because those sockets never opened. One event drives the first attempt and a different event drives all the rest, which is why a loop needs both. - Step 4.
Point twenty clients at the server and stop it. Each one waits a fixed second before retrying.
node herd.mjs "ws://127.0.0.1:9483/echo" fixed 20fixed: 20 reconnect attempts, spread 1005-1008 ms after the server went away 1000-1099 ms #################### 20Twenty attempts inside a 3 ms window. Twenty is a fixture. At twenty thousand this is the burst that stops a restarted service from finishing startup, and it repeats a second later.
- Step 5.
Repeat with a random delay in the same window.
node herd.mjs "ws://127.0.0.1:9483/echo" jitter 20jitter: 20 reconnect attempts, spread 30-987 ms after the server went away 0-99 ms #### 4 100-199 ms ## 2 200-299 ms ### 3 300-399 ms # 1 400-499 ms # 1 500-599 ms # 1 600-699 ms ## 2 700-799 ms ### 3 900-999 ms ### 3The same twenty clients and the same average wait, spread over 957 ms instead of 3. Jitter costs no extra delay on average and removes the spike.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| close 1006 then a retry | The socket was open and the transport died | Correct. This is the common case. See Websocket close code 1006. |
| error event with no close | The connection never opened, under Node | Handle both events. A loop on onclose alone stops here. |
| Delays that double each attempt | Exponential backoff is running | Confirm there is a cap, or attempt 12 waits an hour. |
| Every client retrying in the same 10 ms | Fixed interval, no jitter | Add a random component. Step 5 shows the effect. |
| close 1000 wasClean=true then a retry | A deliberate close is being treated as a failure | Do not reconnect after a normal closure or a policy close. |
Common mistakes
What to check next
- Websocket close code 1006: the code the first retry acts on, and why it is never sent by a server.
- Websocket connection timeout: the timeout that triggers most reconnects in production.
- How to check websocket ping pong: the heartbeat that should have prevented the drop.
- Websocket handshake failed: when every retry fails, read the handshake before tuning the backoff.
FAQ
What should websocket reconnect logic do?
Retry on a failure, not on every close. Back off exponentially with a cap, add a random component, stop after a limit or when the tab is hidden, and resume the application state on success. A socket coming back is not a session restored.
How long should the first retry wait?
Long enough that a single dropped frame costs no round trip, short enough that a user does not notice. The run in step 3 starts near 300 ms and doubles. The figure that matters is total recovery time, 18945 ms here for a twelve second outage.
Why does my reconnect loop stop after one attempt?
Because the first attempt failed before the socket opened, and under Node that produces an error event only. Step 2 prints it. Listen for both events and guard against handling one failure twice.
Can I test reconnect without stopping the server?
Partly. Closing the socket from the server exercises the close path but not the failed-open path, and those are different events. Stopping the process produced the mixed output in step 3.
Does jitter make reconnects slower?
No. Step 5 draws the delay from the same one second window, so the average is unchanged. What moves is the shape: 20 attempts across 957 ms instead of 20 inside 3 ms.
Verified
Verified by Maks Vernynode 22.23.2ws 8.21.3Chrome 152.0.7977.76
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny