Tuning WebSocket idle timeouts across proxies #
Connections that sit quiet for exactly 60 seconds close with code 1006, the server logs nothing useful, and the client reconnects as if nothing happened. Busy connections never show the problem; only the idle ones do, which is why it tends to surface in staging dashboards left open over lunch or in chat rooms nobody has typed in for a minute. The cause is almost never your application. Somewhere between the browser and your Node.js process sits a proxy, load balancer or NAT table with an idle timer shorter than the gap between your frames, and it is silently evicting the flow. This page shows how to find every idle timer on the path, pick the one that actually binds, and size the heartbeat so it always fires first.
Root cause #
Every stateful hop between client and server keeps a per-flow entry and throws it away after a period with no bytes in either direction. The WebSocket protocol has no opinion about this: a connection with nothing to say is, to each middlebox, indistinguishable from one whose peer has vanished. When the entry expires, the hop either sends a RST, sends a FIN, or — worst — just forgets the flow and drops later packets on the floor. The browser eventually sees an abnormal closure with no close frame, which the API reports as 1006, the code described in WebSocket close codes explained.
The binding constraint is the minimum idle timeout across all hops, not the one you configured most recently. A typical production path has four or five timers, owned by different teams, with defaults that were chosen for HTTP request/response traffic rather than long-lived sockets.
Two details make this harder than it looks. First, most of these timers are reset only by data in the direction they watch. nginx’s proxy_read_timeout measures the time since the last read from the upstream, so a chat client that only sends messages still gets cut off if the server never answers. Second, TCP keepalive does not help: keepalive probes are empty ACKs handled by the kernel, and application-layer proxies like nginx and ALB terminate TCP themselves, so a keepalive on one side never reaches the timer on the other.
Resolution #
The fix has two halves: raise the timeouts you control to something comfortably long, and send a heartbeat from the server at an interval well under the shortest timer you do not control. A ping frame counts as data for every hop that inspects bytes, and the browser’s automatic pong counts as data in the opposite direction, so one ping/pong exchange resets timers in both directions at once — which a one-directional application message does not.
import { WebSocketServer, WebSocket } from 'ws';
// The shortest idle timer on the path, measured — not guessed. Update this when
// the path changes (new CDN, new load balancer), and alert if it drops.
const PATH_MIN_IDLE_TIMEOUT_MS = 60_000;
// Fire at well under half the shortest timer so one lost ping never lets it expire.
const SAFETY_FACTOR = 0.4;
const HEARTBEAT_INTERVAL_MS = Math.floor(PATH_MIN_IDLE_TIMEOUT_MS * SAFETY_FACTOR); // 24 s
// A peer that misses this many consecutive pongs is presumed dead.
const MAX_MISSED_PONGS = 2;
interface TrackedSocket extends WebSocket {
missedPongs: number;
lastPongAt: number;
}
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (raw) => {
const ws = raw as TrackedSocket;
ws.missedPongs = 0;
ws.lastPongAt = Date.now();
// Any pong — solicited or not — proves the whole path is still carrying bytes.
ws.on('pong', () => {
ws.missedPongs = 0;
ws.lastPongAt = Date.now();
});
});
const sweep = setInterval(() => {
for (const client of wss.clients) {
const ws = client as TrackedSocket;
if (ws.missedPongs >= MAX_MISSED_PONGS) {
ws.terminate(); // no close handshake: the path is already gone
continue;
}
ws.missedPongs += 1; // cleared by the pong handler if the peer answers
ws.ping(); // counts as traffic for nginx, ALB and the CDN
}
}, HEARTBEAT_INTERVAL_MS);
wss.on('close', () => clearInterval(sweep));
The constant that matters is PATH_MIN_IDLE_TIMEOUT_MS. Treat it as a measured fact about your deployment, recorded in configuration next to the proxy settings, rather than a number someone typed once. When a platform team swaps a load balancer, the heartbeat has to follow.
For the hops you own, raise the timeouts so the heartbeat is the only thing that governs liveness. In nginx that means the two proxy timeouts inside the WebSocket location — see configuring nginx for WebSocket upgrades for the full block:
location /ws/ {
proxy_pass http://realtime_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # upstream silence tolerated before nginx closes
proxy_send_timeout 3600s; # client silence tolerated before nginx closes
}
On an AWS ALB the equivalent is the idle_timeout.timeout_seconds load balancer attribute, covered in configuring AWS ALB for WebSocket sticky sessions. Hops you cannot change — a CDN’s fixed limit, a customer’s corporate proxy — simply become the value of PATH_MIN_IDLE_TIMEOUT_MS.
The interval choice is a straight trade between how much margin you keep and how many control frames you send. The chart below runs the numbers for a 60-second binding timer and a 10-second pong wait.
Verification #
Measure the binding timer instead of trusting documentation. Open a socket through the full production path, disable the heartbeat on a test build, and time how long an idle connection survives:
# Connect through the public hostname with no heartbeat; print when the socket dies.
npx wscat -c wss://realtime.example.com/ws/ --no-color &
START=$(date +%s)
wait
echo "closed after $(( $(date +%s) - START )) s"
Then repeat the test with each hop bypassed in turn (hit the ALB directly, then nginx directly, then the pod) — the survival time jumps as soon as you step past the hop that owns the shortest timer. On the server, confirm the heartbeat is actually reaching clients by comparing ping and pong counters: a pong rate that tracks the ping rate proves round trips are completing, while a widening gap means something in the path swallows control frames.
# On the Node host: established sockets and their idle timers from the kernel's view.
ss -tno state established '( sport = :8080 )' | head -20
The timeline makes the goal concrete: a heartbeat that always lands inside the shortest window, with room for one miss.
Operational checklist #
FAQ #
Why do my WebSockets close after exactly 60 seconds? #
Sixty seconds is the default idle timeout for nginx’s proxy timeouts and for the AWS Application Load Balancer. If connections with no traffic die at that age while busy ones survive, one of those hops is reaping them. Send a server-side ping every 20–25 seconds, and raise the timeout on the hop if you control it.
Does TCP keepalive stop proxies closing idle WebSockets? #
No. Keepalive probes are empty TCP segments handled by the kernel; a layer-7 proxy terminates the TCP connection on each side and only resets its idle timer when it proxies application bytes. You need WebSocket-level traffic — ping frames or application messages — to keep it open.
Can the browser send the heartbeat instead of the server? #
The browser WebSocket API cannot send ping frames. A client can send a small application message, but then the server must answer it, or nginx’s read timer still expires. Server-driven pings are simpler because the browser answers them with an automatic pong.
What interval works behind Cloudflare? #
Cloudflare closes proxied WebSocket connections after roughly 100 seconds without data. A 30-second server ping stays comfortably inside that and also inside a 60-second origin load balancer, so it is a reasonable single value for a Cloudflare-fronted stack.
Is a very short heartbeat harmful? #
At a few seconds, the control traffic starts to matter on mobile radios, where each wake-up costs battery, and on very large fleets, where pings become a measurable share of packets. Stay in the 15–30 second band unless a specific hop forces you lower.
Related #
- Implementing WebSocket Ping-Pong in Node.js — the
isAlivesweep this page tunes. - Detecting Half-Open WebSocket Connections — the failure the same heartbeat exposes from the other side.
- WebSockets Behind Corporate Proxies — hops you cannot configure at all.
- WebSocket Close Codes Explained — reading the
1006an idle reaper leaves behind.
Back to Connection Lifecycle & Heartbeats.