TCP has its own keepalive mechanism, and it seems like the obvious tool for detecting a dead WebSocket connection. Enable it, set an interval, let the operating system tell you when a peer stops responding. In practice, relying on TCP keepalive for WebSocket liveness detection causes more production incidents than it prevents, and the reasons come down to layers of the stack that your application code simply doesn't control.
TCP keepalive answers a different question than you're asking
TCP keepalive checks whether the underlying socket connection is still established at the transport layer. That's not the same question as "is the application on the other end still processing my messages correctly." A server process can be deadlocked, stuck in an infinite loop, or backed up behind a queue that will never drain, while its TCP stack happily responds to keepalive probes because the operating system, not the application, is the one answering them. The connection can be alive in exactly the sense TCP keepalive checks, while being completely useless in the sense your application actually cares about.
Worse, default TCP keepalive intervals are often far too slow for real-time applications. Many operating systems default to a two-hour idle period before the first keepalive probe even fires. You can tune this, but tuning it requires OS-level socket configuration on both ends of the connection, which isn't something most application developers control directly, especially when the client is a browser and the "OS" in question is whatever the user's phone or laptop happens to be running that week.
Proxies and load balancers don't forward TCP-level signals
In any production deployment with a load balancer or reverse proxy in front of your WebSocket server, which is nearly all of them, the browser's TCP connection terminates at the proxy, not at your application server. TCP keepalive between the browser and the proxy tells you nothing about whether the proxy's own connection to your actual backend process is still healthy. The layers don't compose the way keepalive implicitly assumes they will, and a broken backend behind a perfectly healthy proxy connection is exactly the failure mode teams keep discovering the hard way.
Application-level heartbeats work regardless of the network topology
A heartbeat implemented at the application layer, a small ping message sent over the already-open WebSocket, answered with a pong from the actual application handler, tests the thing you actually care about: whether the full path from client to application logic and back is still functioning end to end. It doesn't matter how many proxies, load balancers, or NAT devices sit in between. If the pong comes back within your timeout window, the whole path is alive. If it doesn't, something in that path has failed, and critically, you don't need to know which specific layer failed to react correctly.
This also gives you a tunable detection window that isn't hostage to OS defaults or infrastructure you don't control. A 20 to 30 second heartbeat interval with an 8 to 10 second pong timeout gives you dead-connection detection within roughly 40 seconds worst case, a number you set directly in your own code rather than negotiating with kernel networking parameters across every client platform your app happens to run on, from desktop browsers to embedded WebViews.
A concrete comparison worth internalizing
It helps to picture the two approaches side by side on the same failing scenario. Say a backend process behind a load balancer enters a deadlocked state where its event loop stops processing anything but the TCP stack itself is untouched and still answering low-level probes. Under TCP keepalive alone, the connection looks perfectly healthy indefinitely, the operating system answers on the process's behalf, and no signal ever reaches your client or your monitoring. Under an application-level heartbeat, the very next ping goes unanswered because the deadlocked event loop never gets a chance to send the pong, and your client correctly detects the failure within one heartbeat interval and starts reconnecting to a healthy instance.
That single scenario, a deadlocked process with a healthy TCP stack, is common enough in production systems that it alone justifies building the application-level heartbeat, independent of every other argument in this piece about proxies, NAT devices, and OS-level configuration.

Photo by Brett Sayles on Pexels
What this looks like in practice
The pattern is simple enough to implement in an afternoon: the client sends a lightweight ping payload on an interval, the server responds immediately with a pong, and if the pong doesn't arrive within the timeout window, the client tears down the connection itself and starts its reconnection flow rather than waiting indefinitely for the transport layer to notice. Libraries like Socket.IO implement this pattern out of the box, with configurable ping intervals and timeouts, which is one reason to prefer an established library over rolling your own heartbeat from scratch unless you have a specific reason not to.
A common objection, and why it doesn't hold up
The most frequent pushback on this approach: doesn't sending pings on every connection, potentially thousands of them, add meaningful server load compared to letting the OS handle it for free? In practice, a heartbeat message is tiny, a few bytes of JSON, and responding to one is cheap enough that it's rarely the bottleneck in any system that's actually handling thousands of concurrent WebSocket connections. The load a heartbeat adds is a rounding error next to the load of maintaining the connections themselves, the message routing, and the application logic those connections exist to serve in the first place. Teams that skip application-level heartbeats to save on this cost are usually optimizing the wrong line item.
Tuning the timeout without guessing
A question we get asked often: how do you actually pick the ping interval and pong timeout instead of copying a number from a blog post? Start by measuring your real round-trip time distribution across your actual user base, most application performance monitoring tools already capture this for HTTP requests, and a WebSocket ping is a reasonable proxy for the same network path. Set your pong timeout to comfortably exceed your 95th or 99th percentile round-trip time, not the median, so you're not misclassifying a slow-but-alive connection as dead. Then set your ping interval to whatever cadence gives you an acceptable worst-case detection time given that timeout, and revisit both numbers periodically as your user base and their typical network conditions change.
None of this means TCP keepalive is useless. It's still worth enabling as a defense-in-depth measure for the rare case where your application-level heartbeat itself gets stuck in some unexpected way. But it shouldn't be your primary or only signal for connection liveness. The application layer is where the actual failure you care about will show up first, and it's the layer you have full control over regardless of what infrastructure happens to sit between your client and server on any given deployment.
For the broader picture of what the browser's WebSocket API actually exposes versus what you have to build yourself, MDN's reference is worth reading end to end before you commit to a heartbeat design, and the underlying protocol specification is useful if you need to understand exactly what a close frame does and doesn't guarantee about delivery.
137foundry.com has a full breakdown of the heartbeat, backoff, and message-replay pattern working together in how to build a WebSocket reconnection strategy that doesn't lose messages, including the sequence-numbering approach that heartbeat detection alone doesn't solve on its own.
Top comments (0)