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

// 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));
  }
});
// 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);
// 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));
// 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

  1. Step 1.

    Start the target in its own terminal and leave it running.

    node wsprobe.mjs 9483
    
    22:48:58.955 listening on 9483
  2. 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"; done
    
        32 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 error and nothing else, and three seconds later readyState was still 0, CONNECTING. No close event, no CLOSED state. A reconnect loop written against onclose alone stops here and never runs again. Chrome 152 does not behave this way: driven against the same dead port it fired error then close code=1006 wasClean=false and reached readyState 3, so the same loop passes in a browser and hangs under Node.

  3. 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 9483 and stop that id only.

    node wsretry.mjs "ws://127.0.0.1:9483/echo" jitter 24000
    
        33 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 open

    Read the left column of reasons. The first retry came from close, because that socket had been open. Every later one came from error, 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.

  4. 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 20
    
    fixed: 20 reconnect attempts, spread 1005-1008 ms after the server went away
    1000-1099 ms  #################### 20

    Twenty 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.

  5. Step 5.

    Repeat with a random delay in the same window.

    node herd.mjs "ws://127.0.0.1:9483/echo" jitter 20
    
    jitter: 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  ### 3

    The 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

Sign: The reconnect loop works in the browser and never retries under Node or in a Node test.Cause: Node 22.23.2 fires error and no close when the connection never opens, and readyState stays 0. Chrome 152 fires error then close with code 1006 and reaches readyState 3. A loop keyed to onclose alone passes the browser check and hangs in CI.
Sign: One failure schedules two reconnect attempts and the delays halve.Cause: Listening to both error and close is required, and both fire for a socket that was open. Without a flag that marks the failure as handled, each one schedules its own timer and the backoff runs at twice the intended rate.
Sign: A restarted server is knocked over again a second after it comes up.Cause: Every client was disconnected at the same instant and every client waits the same interval, so the retry is synchronised. Step 4 puts twenty attempts in a 3 ms window. The fix is a random delay, not a longer one.
Sign: The client reconnects after the server closed it on purpose, and does it forever.Cause: A close with 1000 or a policy code is a decision, not a fault. A loop that retries on every close argues with the server, and an authentication failure becomes a permanent loop no log makes sense of.

What to check next

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.

intermediate10 minpublished updated Maks Verny