Why TCP keepalive is not enough
TCP has its own keepalive, but the OS defaults are minutes-to-hours and you usually cannot tune them per-process. Application-level ping/pong runs on your terms — every 30 seconds, expect a pong within 5 seconds, otherwise close.
Detecting silent half-open connections
A laptop lid that closed mid-session, a phone that switched from wifi to cellular, a NAT that timed out: all leave the server-side socket in a state where send still seems to succeed (the kernel buffer accepted the bytes) until the OS finally times out. Heartbeat catches these in seconds, not minutes.
Server vs. client heartbeat
Either side can drive the heartbeat. Server-driven (server pings, client pongs) is simpler — only one timer in the server. Client-driven (client pings, server pongs) lets dumb clients (IoT, mobile-with-tight-battery-budget) skip pinging when nothing else is happening. Pick one and document it in your protocol.