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
- Node 22 or later. See MDN on WebSocket.
npm i ws@8in an empty directory, and the ws documentation on terminate.- RFC 6455, which specifies no idle timeout anywhere.
timeoutfrom GNU coreutils for the two observation windows, or Ctrl+C after the stated number of seconds.- A free port. Check with
netstat -ano | grep 9481first and pick another number if anything answers. - The target server,
wsprobe.mjs. Its/idlepath runs the sweep most Node services copy from thewsdocumentation: ping every client, terminate any that did not pong since the previous pass.
// 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));
}
});
- A client that stays silent,
rawws.mjs. It handshakes by hand and answers nothing, which is what a stalled peer looks like from the server.
// 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));
- A client that behaves,
wslog.mjs. It uses the browser API, which answers pings for you.
// 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
- Step 1.
Start the target in its own terminal and leave it running.
node wsprobe.mjs 948122:40:54.395 listening on 9481 - 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 ProtocolsOne line in twelve seconds of silence, and the socket was still open when
timeoutstopped the client. Nothing in the protocol or the library closes an idle connection, which is why every timeout you meet was configured by somebody. - 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"; done5 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 frameEach 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.
- 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 1One line again, and no close event. The client sent no application data for eight seconds and the sweep never touched it.
- 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
timeoutstopping 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
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 VerifiedCommon mistakes
What to check next
- How to check websocket ping pong: the sweep is built out of control frames, and the browser cannot see them.
- Websocket close code 1006: what a terminated socket looks like from the client.
- How to test websocket reconnect: what should happen after the timeout fires.
- Websocket max connections: sockets that are never swept accumulate until the limit is the next thing you meet.
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.
Related on this site
intermediate8 minpublished updated Maks Verny