C.W.K.
Stream
Lesson 04 of 07 · published

Heartbeat & Keepalive

~13 min · management, heartbeat, ping

Level 0Poller
0 XP0/60 lessons0/10 achievements
0/120 XP to next level120 XP to go0% complete

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.

Code

Server-driven heartbeat·python
import asyncio, time

class HeartbeatManager:
    def __init__(self, *, interval=30, timeout=5):
        self.interval = interval
        self.timeout = timeout
        self.last_pong: Dict[WebSocket, float] = {}

    async def run(self, ws: WebSocket):
        self.last_pong[ws] = time.time()
        try:
            while True:
                await asyncio.sleep(self.interval)
                if ws.client_state.value != 1:  # 1 == CONNECTED
                    return
                await ws.send_json({'type': 'ping'})
                # Give the client `timeout` seconds to pong.
                await asyncio.sleep(self.timeout)
                age = time.time() - self.last_pong.get(ws, 0)
                if age > self.interval + self.timeout:
                    await ws.close(code=4000, reason='heartbeat timeout')
                    return
        finally:
            self.last_pong.pop(ws, None)

    def handle_pong(self, ws: WebSocket):
        self.last_pong[ws] = time.time()

heartbeat = HeartbeatManager()

@app.websocket('/ws')
async def with_heartbeat(websocket: WebSocket):
    await websocket.accept()
    asyncio.create_task(heartbeat.run(websocket))
    try:
        async for msg in websocket.iter_json():
            if msg.get('type') == 'pong':
                heartbeat.handle_pong(websocket)
                continue
            # ... handle other messages
    except WebSocketDisconnect:
        pass

External links

Exercise

Wire the heartbeat manager into your echo server. Connect a client, then kill -STOP its process to simulate a frozen client. Within 35 seconds (interval + timeout), the server must close the connection with code 4000. Without heartbeat, that same dead client could persist for minutes.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.