A WebSocket server with a hundred thousand connections always holds some that are already dead. A phone went into a tunnel, a laptop lid closed, a NAT box forgot its mapping. Nothing told the server, and TCP will not tell it for a long time. Meanwhile each dead connection holds memory, file descriptors, subscriptions and presence state, and every broadcast wastes work on it.
Ping and Pong control frames are the protocol's answer. The idea is simple, but the details decide whether it works: who sends, how the reply is detected, which timers sit along the path, and what happens when the server is too busy to read. The companion article Heartbeat and keep-alive strategies compares keepalive across TCP, HTTP/2, gRPC, SSE and MQTT and covers browser-side heartbeats. This one stays on the server and goes into the mechanics, with working code in three languages.
What the protocol gives you
RFC 6455 defines Ping (opcode 0x9) and Pong (opcode 0xA) as control frames; the byte layout is covered in WebSocket frame format deep dive. A few rules matter for keepalive design:
- Control frames are small and never fragmented. Payload is at most 125 bytes.
- They can be interleaved between fragments of a data message, but not inside a frame. If your server writes one 20 MB message as a single frame, a Ping queued behind it waits until the whole frame is on the wire.
- Replies are loose. A Pong must echo the Ping's payload, but if several Pings arrive before a reply goes out, an endpoint may answer only the most recent one. Do not count Pings against Pongs one-to-one; track the time since the last Pong.
- Unsolicited Pongs are allowed as a one-way heartbeat that expects no reply.
- Browsers reply to Pings automatically and expose neither frame to JavaScript, so on the server side protocol Pings are the natural tool.
Why the server cannot rely on TCP
A half-open connection is one where one side has vanished without sending a FIN or RST. The other side sees nothing wrong. Reads simply block. Writes succeed, because a write only copies bytes into the kernel's send buffer; failure appears only after TCP has retransmitted for long enough to give up. On Linux that is governed by tcp_retries2, whose default of 15 gives a hypothetical timeout of roughly 924.6 seconds according to the kernel documentation, about 15 minutes. And if the server is not writing anything, nothing is retransmitted and nothing fails.
TCP keepalive exists but is off by default on most sockets, and on Linux the default idle time before the first probe is two hours (tcp_keepalive_time = 7200). It also only checks one hop: if a proxy terminates TCP, keepalive tells you the proxy is alive, not the client. See TCP in depth for the retransmission machinery.
There is a second reason to send traffic: middleboxes drop idle connections. Typical defaults include 60 seconds for nginx's proxy_read_timeout and for an AWS Application Load Balancer's idle timeout, and Amazon API Gateway WebSocket APIs document a 10-minute idle timeout and a 2-hour maximum connection duration. A Ping on a shorter period keeps these timers from firing on quiet but healthy connections.
The two timers
Every server keepalive implementation reduces to two numbers. The ping period P is how often the server sends a Ping. The deadline W is how long the server waits, since it last heard anything, before declaring the peer dead. The constraints are:
- P must be shorter than every idle timeout on the path, with margin for jitter. With a 60-second load balancer, 25 to 30 seconds is typical.
- W must be longer than P plus the worst healthy round-trip time plus any stall in your own process, or slow but healthy clients get disconnected.
- Worst-case detection is about P + W. Shorter means more traffic and more false positives on flaky mobile networks.
Go: deadlines instead of counters
In gorilla/websocket the idiomatic pattern uses the read deadline as the liveness timer. Each Pong, and each data message, pushes the deadline forward; if it expires, the next read fails and the connection is torn down. A separate writer goroutine sends Pings on a ticker, so every write happens from one goroutine. The constants below are the ones used in gorilla's chat example, where the ping period is nine tenths of the pong wait.
const (
pongWait = 60 * time.Second // W: how long we wait to hear anything
pingPeriod = (pongWait * 9) / 10 // P: must be shorter than W
writeWait = 10 * time.Second
)
func (cl *Client) readPump() {
defer cl.hub.unregister(cl)
c := cl.conn
c.SetReadLimit(64 << 10)
c.SetReadDeadline(time.Now().Add(pongWait))
c.SetPongHandler(func(string) error {
return c.SetReadDeadline(time.Now().Add(pongWait))
})
for {
// Control frames (Ping, Pong, Close) are processed inside this call.
// If nothing is reading, the pong handler never runs.
_, msg, err := c.ReadMessage()
if err != nil {
return // deadline exceeded, close frame or network error
}
c.SetReadDeadline(time.Now().Add(pongWait)) // data also proves liveness
cl.hub.inbound <- msg // keep this non-blocking in practice
}
}
func (cl *Client) writePump() {
t := time.NewTicker(pingPeriod)
defer func() { t.Stop(); cl.conn.Close() }()
for {
select {
case msg, ok := <-cl.send:
cl.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
cl.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := cl.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-t.C:
cl.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := cl.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}The important line is the comment inside the read loop. The library handles control frames as part of reading. A Pong that arrives while no goroutine is reading is never processed, so the deadline is never extended. If the read loop blocks on a full channel (the cl.hub.inbound send above), a slow consumer turns into false keepalive timeouts for healthy clients. Keep the read loop doing nothing but read, and drop or buffer downstream.
Node.js: a mark-and-sweep loop
The ws library replies to Pings automatically and emits a pong event. Its documented pattern for detecting broken connections is a single interval that sweeps all clients: mark each one as not alive and Ping it; anyone still unmarked on the next sweep is terminated.
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const INTERVAL_MS = 30_000;
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
ws.on("message", () => { ws.isAlive = true; }); // any frame is proof of life
});
const sweep = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate(); // not close(): a dead peer will never finish the handshake
continue;
}
ws.isAlive = false;
ws.ping();
}
}, INTERVAL_MS);
wss.on("close", () => clearInterval(sweep));One timer for all connections is cheap, and detection takes between one and two intervals. Use terminate() rather than close(): a close handshake needs the peer to answer, and a dead peer never will.
Python: built in, with one sharp edge
The websockets library sends a Ping every 20 seconds by default and expects a Pong within 20 seconds; if none arrives it considers the connection broken and closes it with code 1011. The latency attribute exposes the RTT of the last exchange, which is a free health metric.
import asyncio
from websockets.asyncio.server import serve
async def handler(ws):
async for msg in ws:
await ws.send(msg)
# ws.latency holds the RTT measured by the last Ping/Pong exchange
async def main():
# The defaults are already 20 s / 20 s; set them explicitly so they are reviewed.
async with serve(handler, "0.0.0.0", 8765, ping_interval=20, ping_timeout=20) as server:
await server.serve_forever()
asyncio.run(main())The sharp edge is the event loop. Pongs are processed by the same loop as your handlers, so any blocking call (a synchronous database query, a CPU-heavy JSON encode, a slow log handler) delays them. Block for longer than ping_timeout and every connection on that process can be closed at once. If you see 1011 closes in bursts, look for loop stalls before blaming the network.
Proxies and who actually answers
A Ping reaches the endpoint that terminates the WebSocket. A layer-4 load balancer or an HTTP proxy that tunnels the upgraded connection, such as nginx with proxy_pass and the Upgrade headers, forwards frames untouched, so the Pong comes from the real client and resets the proxy's idle timer on the way. A gateway that terminates WebSockets itself and talks to your backend separately may answer Pings itself. Then a successful Pong proves only that the gateway is alive.
Know which kind each hop is, and set every tunnelling proxy's timeout well above P. The connection-lifetime histogram tells you if you got it wrong: a sharp spike at exactly 60 seconds means some hop's idle timeout is beating your ping period. Load balancing WebSockets covers the balancer side in more detail.
Worked example: a 200,000-connection fleet
A notification service runs 200,000 connections across 10 servers behind a load balancer with a 60-second idle timeout. Choose P = 25 s and W = 60 s.
| Quantity | Calculation | Result |
|---|---|---|
| Pings per second, fleet | 200,000 / 25 | 8,000 |
| Pings per second, per server | 8,000 / 10 | 800 |
| Wire bytes per Ping and Pong | 2-byte header + 4-byte mask on client frames, empty payload | about 8 bytes before TCP/IP and TLS overhead |
| Worst-case detection | P + W | 85 s |
| Margin under LB idle timeout | 60 - 25 | 35 s |
800 tiny frames per second per server is negligible. The real costs are elsewhere. Packet and TLS-record overhead dominates the byte count, and on mobile every Ping can wake the radio, which is why mobile-heavy services push P toward the largest value their proxies allow. Each dead connection held for up to 85 s still costs memory; at a churn of 50 disconnects per second that is at most about 4,250 zombie connections fleet-wide, an acceptable cost. If presence must update faster than 85 s, shrink W, not just P.
Close codes you will see
- 1006 (abnormal closure) is never sent on the wire. Libraries report it when the connection ended without a Close frame: a reset, a timeout, a dead peer. A rise in 1006 is a network or keepalive signal, not an application error.
- 1011 (internal error) is what Python websockets uses on keepalive timeout, so it can mean a dead peer or your own stalled loop.
- 1001 (going away) is what a server should send when it shuts down for a deploy, so clients reconnect without alarm.
- Codes 4000 to 4999 are free for applications; use one for an application-level heartbeat timeout so it is distinguishable in logs.
Failure modes
- Nobody reading. Pongs are processed on read; a stalled reader produces false timeouts.
- Pings behind a giant frame. Fragment or cap large messages so control frames can be interleaved.
- Synchronised pings. After a deploy, every connection's timer starts at the same instant. Add jitter or use one sweep timer.
- Ping period above a proxy timeout. Connections die exactly at the proxy's limit, no matter how healthy.
- Trusting a gateway's Pong. A terminating gateway can keep a connection alive whose real client vanished long ago.
- Graceful close to a dead peer. Waiting for a Close reply that never comes leaks the connection; set a short timeout and then terminate.
Trade-offs
Shorter ping periods mean faster detection, more battery and bandwidth on clients, and more false positives on lossy links. Longer ones are cheaper but keep zombies around and risk proxy timeouts. Server-driven protocol Pings are cheap and invisible to application code, but they cannot be seen or sent from browser JavaScript and they only prove liveness to the terminating hop. Application heartbeats are visible to both sides and pass through terminating gateways, at the cost of a little code and some message overhead. Many production systems run both: protocol Pings for server cleanup, an application heartbeat for client-visible connection state. For the wider scaling picture, see WebSocket scaling patterns.
What to do next
- List every hop between client and server and write down each idle timeout and whether it terminates WebSockets.
- Set P below the smallest idle timeout with margin, and W above P plus worst healthy RTT plus your worst event-loop stall.
- Make sure every connection has an active reader at all times, and that the read path never blocks on downstream work.
- Cap or fragment large outbound messages so Pings are not stuck behind them.
- Export Pong RTT, keepalive timeouts per minute and a connection-lifetime histogram; look for spikes at proxy timeout values.
- Send 1001 on shutdown, and terminate rather than gracefully close peers that failed keepalive.