Heartbeats: ping, pong & keep-aliveHeartbeat: ping, pong и keep-alive
The silent death problemПроблема «тихой смерти»
TCP can fail without telling either side. If a phone loses signal or a NAT table entry expires, packets simply stop arriving — no close event fires. This is a half-open connection: your code still thinks readyState === 1, but messages vanish into the void.
On top of that, reverse proxies and load balancers aggressively close connections that look idle. A WebSocket carrying no traffic for 30–60 seconds is a prime candidate for an 504 or a silent drop.
TCP может отказать, не сообщив ни одной из сторон. Если телефон теряет сигнал или истекает запись в таблице NAT, пакеты просто перестают приходить — событие close не срабатывает. Это полуоткрытое соединение: код всё ещё считает, что readyState === 1, но сообщения уходят в пустоту.
Вдобавок обратные прокси и балансировщики агрессивно закрывают соединения, которые выглядят простаивающими. WebSocket без трафика 30–60 секунд — первый кандидат на 504 или тихий разрыв.
Protocol-level ping/pongPing/pong на уровне протокола
RFC 6455 defines control frames 0x9 (ping) and 0xA (pong). A browser cannot send a ping from JavaScript, but it answers server pings automatically. So the canonical pattern is: server pings, client auto-pongs, and the server drops anyone who fails to pong in time.
import { WebSocketServer } from "ws"; const wss = new WebSocketServer({ port: 8080 }); function heartbeat() { this.isAlive = true; } wss.on("connection", (ws) => { ws.isAlive = true; ws.on("pong", heartbeat); // client answered our ping }); // Sweep every 30s: anyone who didn't pong is dead. const timer = setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); ws.isAlive = false; ws.ping(); }); }, 30000); wss.on("close", () => clearInterval(timer));
RFC 6455 определяет управляющие кадры 0x9 (ping) и 0xA (pong). Браузер не может послать ping из JavaScript, но автоматически отвечает на ping сервера. Поэтому канонический шаблон: сервер пингует, клиент авто-понгует, а сервер отключает тех, кто не ответил вовремя.
import { WebSocketServer } from "ws"; const wss = new WebSocketServer({ port: 8080 }); function heartbeat() { this.isAlive = true; } wss.on("connection", (ws) => { ws.isAlive = true; ws.on("pong", heartbeat); // клиент ответил на наш ping }); // Проход каждые 30с: кто не ответил pong — мёртв. const timer = setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); ws.isAlive = false; ws.ping(); }); }, 30000); wss.on("close", () => clearInterval(timer));
Application-level heartbeatsHeartbeat на уровне приложения
Because the browser can't send protocol pings, clients often need their own JSON-level heartbeat to detect a dead server. Send a tiny message on a timer and expect a reply within a window:
let pongTimer; function startHeartbeat(ws) { setInterval(() => { ws.send('{"type":"ping"}'); pongTimer = setTimeout(() => ws.close(), 5000); // no pong → dead }, 25000); ws.addEventListener("message", (e) => { if (e.data === '{"type":"pong"}') clearTimeout(pongTimer); }); }
Pick an interval shorter than the most aggressive proxy timeout in your path — 25–30 seconds is a safe default. Too frequent wastes battery on mobile; too rare lets connections lapse.
Поскольку браузер не может слать протокольные ping-и, клиентам часто нужен собственный heartbeat на уровне JSON, чтобы обнаружить мёртвый сервер. Отправляйте крошечное сообщение по таймеру и ждите ответа в заданном окне:
let pongTimer; function startHeartbeat(ws) { setInterval(() => { ws.send('{"type":"ping"}'); pongTimer = setTimeout(() => ws.close(), 5000); // нет pong → мёртв }, 25000); ws.addEventListener("message", (e) => { if (e.data === '{"type":"pong"}') clearTimeout(pongTimer); }); }
Выбирайте интервал короче самого агрессивного таймаута прокси на пути — 25–30 секунд безопасны по умолчанию. Слишком часто — расход батареи на мобильных; слишком редко — соединения отваливаются.
Tie it togetherСоберите всё вместе
A heartbeat tells you a socket is dead; reconnection logic brings it back. When the client's pong timer fires, call close() — your reconnecting wrapper's onclose handler then kicks off the backoff loop. The two patterns are designed to work as a pair.
Heartbeat сообщает, что сокет мёртв; логика переподключения возвращает его к жизни. Когда срабатывает pong-таймер клиента, вызовите close() — обработчик onclose вашей обёртки запустит цикл backoff. Эти два паттерна задуманы работать в паре.