Websocket connection timeout

The protocol has no idle timeout, so measure the one your server was given. Point a client that never answers a ping at the liveness sweep: node rawws.mjs 9481 "/idle?every=3000" prints a ping at 3020 ms and FIN from server at 6023 ms. The socket dies at twice the sweep interval, with no close frame.

Why check this

Run this before sign-off on any feature that holds a socket open through quiet periods, after a proxy is put in front of the service, and whenever a report says the connection drops after a few minutes with nothing in the application log.

The failure it prevents is a timeout nobody wrote down. A server sweeps idle connections every 30 seconds, a proxy in front of it cuts anything quiet for 60, and the client heartbeat runs every 90. Each number is defensible and together they drop every session that goes quiet, at an interval no one configuration file explains.

The check gives you the interval between the last sign of life and the close, and which side closed. It cannot separate the server from an intermediary, so run it twice: against the server, then through the proxy the browser uses.

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));
  }
});
// rawws.mjs - hand-rolled handshake, then hexdump every byte the server sends.
// node rawws.mjs 9481 "/close?code=1000&reason=done"
import net from 'node:net';
import crypto from 'node:crypto';
const [port, path] = [process.argv[2], process.argv[3]];
const t0 = Date.now();
const at = () => String(Date.now() - t0).padStart(6) + ' ms';
const key = crypto.randomBytes(16).toString('base64');
const s = net.connect(Number(port), '127.0.0.1', () => {
  s.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
});
let head = false;
s.on('data', (b) => {
  if (!head) { const i = b.indexOf('\r\n\r\n'); console.log(at(), b.subarray(0, b.indexOf('\r\n')).toString()); head = true; b = b.subarray(i + 4); if (!b.length) return; }
  console.log(at(), 'frame', b.toString('hex').replace(/(..)/g, '$1 ').trim());
  if (b[0] === 0x88) { console.log(at(), 'close frame, leaving'); process.exit(0); }
});
s.on('end', () => console.log(at(), 'FIN from server, no close frame'));
s.on('close', () => process.exit(0));
// wslog.mjs - Node 22 global WebSocket, the same API a browser gives you.
// node wslog.mjs "ws://127.0.0.1:9481/close?code=1001"
const t0 = Date.now();
const log = (...a) => console.log(String(Date.now() - t0).padStart(6), 'ms', ...a);
const ws = new WebSocket(process.argv[2]);
ws.onopen = () => log('open, readyState', ws.readyState);
ws.onmessage = (e) => log('message', JSON.stringify(e.data));
ws.onerror = (e) => log('error event, type', e.type);
ws.onclose = (e) => {
  log('close  code=' + e.code, 'reason=' + JSON.stringify(e.reason), 'wasClean=' + e.wasClean);
  process.exit(0);
};

Steps

  1. Step 1.

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

    node wsprobe.mjs 9481
    
    22:40:54.395 listening on 9481
  2. Step 2.

    Take the baseline: a path with no sweep, and a client that sends nothing for twelve seconds.

    timeout 12 node rawws.mjs 9481 "/echo"
    
         3 ms HTTP/1.1 101 Switching Protocols

    One line in twelve seconds of silence, and the socket was still open when timeout stopped the client. Nothing in the protocol or the library closes an idle connection, which is why every timeout you meet was configured by somebody.

  3. Step 3.

    Measure the sweep. Run the silent client against three sweep intervals and read the time to FIN.

    for e in 1000 2000 3000; do node rawws.mjs 9481 "/idle?every=$e"; done
    
         5 ms HTTP/1.1 101 Switching Protocols
    1009 ms frame 89 00
    2016 ms FIN from server, no close frame
       7 ms HTTP/1.1 101 Switching Protocols
    2008 ms frame 89 00
    4013 ms FIN from server, no close frame
       5 ms HTTP/1.1 101 Switching Protocols
    3020 ms frame 89 00
    6023 ms FIN from server, no close frame

    Each connection ends at twice its sweep interval. The first pass pings and marks the connection unproven, the second finds no pong and terminates it. A service configured with a 30 second sweep drops a stalled client after 60.

  4. Step 4.

    Run a client that answers, against the same sweep, for eight seconds.

    timeout 8 node wslog.mjs "ws://127.0.0.1:9481/idle?every=2000"
    
        56 ms open, readyState 1

    One line again, and no close event. The client sent no application data for eight seconds and the sweep never touched it.

  5. Step 5.

    Read the server terminal for that same connection.

    22:58:28.449 open /idle?every=2000
    22:58:30.462 sweep: ws.ping()
    22:58:30.463 pong from client
    22:58:32.474 sweep: ws.ping()
    22:58:32.475 pong from client
    22:58:34.476 sweep: ws.ping()
    22:58:34.477 pong from client
    22:58:36.392 server close event 1006 ""

    Three pongs, from a client whose own code answered nothing. The final 1006 is timeout stopping the client process, not the sweep. The sweep measures whether the peer reads its socket, and an idle application passes.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | No close after minutes of silence | No sweep and no intermediary timeout on this path | Add one. An unreadable half-open socket otherwise lives until TCP notices. | | FIN at twice the configured interval | A two-pass liveness sweep, the common Node pattern | Use the doubled number in the client's heartbeat budget, not the configured one. | | A close at a round number such as 60 s | Almost always an intermediary, not the application | Repeat the run through the proxy and against the server directly, and compare. | | The client reports 1006 | The socket was terminated with no close frame | Expected from terminate(). See Websocket close code 1006. | | Pongs arrive while the application is stuck | Liveness passed, usefulness did not | Add an application-level heartbeat the handler answers. |

Thresholds

termination at 2x the sweep interval

Sweeps of 1000, 2000 and 3000 ms terminated a silent client at 2016, 4013 and 6023 ms. The doubling is structural, not rounding: one pass marks, the next kills. Budget the client heartbeat against the doubled figure. These come from a loopback connection with no network delay, so a real path adds to them.

Source: measured on this machine, 2026-09-12, see Verified

Common mistakes

Sign: The heartbeat is set to the server's configured sweep interval and connections still drop.Cause: A two-pass sweep gives a peer one full interval to answer, so the real deadline is between one and two intervals depending on where the connection lands in the cycle. Step 3 measures the upper bound at exactly twice.
Sign: No close code ever reaches the client, and the logs show nothing.Cause: terminate() destroys the socket without sending a close frame, so the peer synthesises 1006 with an empty reason and the server records 1006 as well. Neither log contains the word timeout. Search for 1006, not for a message.
Sign: An idle timeout measured against the service does not reproduce in the browser.Cause: The browser reaches the service through a load balancer or reverse proxy with a timeout of its own, usually the shortest one on the path. Measure through the same hostname the front end uses, not against the origin port.
Sign: A connection that answers every ping is reported as working while users see a frozen screen.Cause: The pong is produced by the library's receiver before any application code runs, as step 5 shows. Transport liveness and application liveness are different checks and need different probes.

What to check next

FAQ

What is the default websocket timeout?

There is not one. RFC 6455 specifies no idle timeout, and step 2 holds a silent connection open for twelve seconds with nothing happening. Every timeout you observe comes from a server sweep, a proxy, or an operating system keepalive.

Why does my websocket time out after 60 seconds?

A round number is the signature of an intermediary. Reverse proxies and cloud load balancers each carry an idle timeout of their own, and the shortest one on the path wins. Read it from that product's configuration, then confirm it by running step 3 through the same hostname.

Does sending application messages reset the timeout?

For a proxy, usually: any traffic in either direction counts. For the sweep in step 3, no. It tracks pongs only, so a chatty client that cannot answer control frames is still terminated.

How do I tell a server timeout from a network drop?

Both give the client 1006. Compare the logs. A sweep leaves a server line at the moment it fires and repeats at an exact interval. A network drop leaves nothing on the server and the intervals scatter.

Verified

Verified by Maks Vernynode 22.23.2ws 8.21.3

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.

intermediate8 minpublished updated Maks Verny