A WebSocket connection can die without firing a close event. NAT devices time out idle connections silently. Mobile carriers drop sessions without a clean TCP teardown. The browser's WebSocket object keeps reporting readyState === 1 (open) long after the underlying connection is functionally gone. If your app relies solely on the close event to trigger reconnection, you'll have users staring at a stale dashboard with no indication anything is wrong, sometimes for minutes at a time.
Here's a practical, step-by-step approach to detecting a dead connection reliably, before your users have to tell you about it in a support ticket.
Step 1: Implement an application-level heartbeat
Don't rely on TCP keepalive or the WebSocket protocol's built-in ping and pong control frames alone. Many browser WebSocket implementations don't expose ping and pong control frames to application code at all, and many corporate proxies strip them in transit. Build your own heartbeat at the application layer instead, where you have full control over the timing and the failure path.
function startHeartbeat(socket, intervalMs = 25000, timeoutMs = 10000) {
let pongTimeout;
const interval = setInterval(() => {
if (socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: "ping" }));
pongTimeout = setTimeout(() => {
socket.close(); // force teardown, trigger reconnect logic
}, timeoutMs);
}, intervalMs);
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
if (data.type === "pong") clearTimeout(pongTimeout);
});
return () => clearInterval(interval);
}
The server side needs a matching handler that responds to ping with pong immediately, ideally ahead of any queued work, so the round-trip time reflects actual connection health rather than server load at that moment.
Step 2: Pick an interval that balances detection speed against overhead
A shorter heartbeat interval detects dead connections faster but adds more network chatter and server-side load at scale, especially once you're running thousands of concurrent connections through a single backend fleet. A 20 to 30 second interval with an 8 to 10 second pong timeout is a reasonable starting point for most consumer applications. Tighten it for latency-sensitive use cases like collaborative editing or live trading dashboards, where a 30-second detection lag is genuinely unacceptable to the people using the product.
Step 3: Distinguish "no pong" from "socket already closed"
Your heartbeat's failure path and your close event listener will often fire close together, sometimes within milliseconds of each other. Make sure both paths funnel into the same reconnection function, and that the function is idempotent, calling it twice for the same dead connection shouldn't accidentally open two new sockets in parallel.
let reconnecting = false;
function handleConnectionLost() {
if (reconnecting) return;
reconnecting = true;
cleanupHeartbeat();
scheduleReconnect(); // sets reconnecting = false on next successful open
}
Step 4: Surface connection state in the UI, don't just log it
Once you can reliably detect a dead connection, expose that state to your interface. A small "reconnecting..." indicator does more for user trust than any amount of backend correctness, because it tells the user the stale data on screen is a known, temporary condition rather than a silent bug they need to worry about or report.
Step 5: Test it without waiting for a real network failure
Use your browser's DevTools to throttle to "Offline" mid-session, or write an integration test that kills the server-side socket directly and asserts your heartbeat timeout fires within the expected window. Load testing tools like Artillery support scripting WebSocket disconnects directly, which is useful for verifying heartbeat behavior under concurrent connection churn rather than a single manually tested session. Reconnection code that's never actually been forced to fail in a test environment is reconnection code nobody has actually verified.
Step 6: Watch for false positives from legitimate slow responses
A heartbeat timeout that's too aggressive relative to your actual network conditions will produce false positives, disconnecting perfectly healthy connections just because a pong happened to arrive a few hundred milliseconds late under load. Log every heartbeat timeout event with enough context (round-trip time history, server load at the time) to distinguish a genuinely dead connection from a heartbeat interval that's simply tuned too tight for your actual traffic patterns.
Step 7: Account for mobile background and tab-suspension behavior
Browsers and mobile operating systems aggressively throttle or suspend background tabs and apps to save battery, which means your heartbeat's setInterval timer may simply not fire on schedule while the tab is backgrounded. When the tab or app comes back to the foreground, don't trust that the connection is still healthy just because no heartbeat failure was recorded, actively re-verify with an immediate ping rather than waiting for the next scheduled interval. This single check catches a large share of "the app looks stuck after I switched tabs and came back" bug reports.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
sendImmediatePing(socket); // don't wait for the next scheduled interval
}
});
Step 8: Give the heartbeat its own error boundary
Wrap the heartbeat send and timeout logic in its own error handling, separate from the rest of your message processing code. A heartbeat that throws an uncaught exception because of an unrelated bug elsewhere in the socket handler can silently stop running altogether, which means you lose dead-connection detection exactly when something else has already gone wrong and you need it most. Treat the heartbeat as a small, isolated subsystem that keeps running independently of whatever else is happening on the connection.
Step 9: Give operators visibility into detection performance
Expose a simple internal metric for how long, on average, it takes your system to detect a dead connection in production, from the last successful heartbeat to the moment reconnection logic kicks in. This number tends to drift over time as your user base's device and network mix changes, and having it visible on a dashboard means someone notices the drift during a routine review instead of during an incident where slow detection made things worse than they needed to be.
Step 10: Don't forget the server-initiated close case
So far this covers the client detecting a dead connection. The reverse also needs handling: the server may need to deliberately close a connection, for a deploy, for a policy reason, for load shedding, and it should send a clear close code and reason string rather than just dropping the socket. A client that distinguishes a deliberate, informative server close from an unexplained drop can skip the ambiguity entirely and respond appropriately, sometimes without needing the full heartbeat timeout window to elapse first.
Bringing it together
Heartbeat-based dead connection detection is the foundation the rest of a reconnection strategy sits on top of: backoff, message replay, and idempotent delivery all assume you already know, promptly and reliably, that the connection is gone. Skipping this step and relying on the browser's native close event alone is the single most common reason reconnection logic looks correct in testing and fails silently in production.
137Foundry's web development team covers the full pattern, including the replay and idempotency pieces that heartbeat detection alone doesn't solve, in our guide to WebSocket reconnection strategy.
For background on what the WebSocket protocol actually promises versus what applications have to build themselves, the MDN reference is worth bookmarking before you write your own heartbeat implementation, and the WebSocket RFC is the authoritative source if you need to settle a disagreement about close code semantics with a teammate.
Top comments (0)