A client can appear connected but be completely unreachable — they closed their laptop, lost network silently, or the OS killed the app. These are called zombie connections. They consume server memory indefinitely. Heartbeats detect and kill them.


How It Works

  1. Server sends a Ping frame every N seconds to every client
  2. Client replies automatically with a Pong frame (browser handles this)
  3. If no Pong arrives before the next interval → connection is dead
  4. Server calls ws.terminate() to force-close the zombie

Server-Side Heartbeat

// server.js
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

const PING_INTERVAL = 30_000; // Ping every 30 seconds

wss.on('connection', (ws) => {
  ws.isAlive = true; // Mark alive on connect

  // Reset alive flag every time a pong arrives
  ws.on('pong', () => {
    ws.isAlive = true;
  });

  ws.on('message', (data) => {
    // handle messages...
  });
});

// Heartbeat interval — runs every 30 seconds
const heartbeat = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      console.log('💀 Zombie connection detected — terminating');
      return ws.terminate(); // Force close — no graceful handshake
    }

    // Assume dead until proven alive
    ws.isAlive = false;
    ws.ping(); // Send protocol-level ping frame
  });
}, PING_INTERVAL);

// Clean up when server closes
wss.on('close', () => {
  clearInterval(heartbeat);
});

The browser's WebSocket API handles pong replies automatically. You don't write any pong logic on the client side.


Application-Level Heartbeat (JSON Ping)

If you want heartbeats visible in your message handler (useful for debugging or measuring latency):

// client.js — app-level ping
let pingInterval;

ws.onopen = () => {
  pingInterval = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({
        type:      'ping',
        timestamp: Date.now(),
      }));
    }
  }, 25_000); // Every 25s (before server's 30s timeout)
};

ws.onmessage = (e) => {
  const data = JSON.parse(e.data);

  if (data.type === 'pong') {
    const latency = Date.now() - data.echo;
    console.log(`♥️ Latency: ${latency}ms`);
    return; // Don't process heartbeats as real messages
  }

  handleMessage(data);
};

ws.onclose = () => {
  clearInterval(pingInterval);
};
// server.js — respond to app-level pings
ws.on('message', (raw) => {
  const msg = JSON.parse(raw);

  if (msg.type === 'ping') {
    ws.send(JSON.stringify({
      type:    'pong',
      echo:    msg.timestamp,
      latency: Date.now() - msg.timestamp,
    }));
    return;
  }

  // Handle other message types...
});

Protocol Ping vs App-Level Ping

Protocol Ping (ws.ping()) App-Level Ping (JSON)
Who handles pong Browser auto-responds You write pong logic
Visible in JS No (browser hides it) Yes (onmessage fires)
Can measure latency No Yes
Overhead Minimal (2-byte frame) Slightly more
Use for Zombie detection Latency monitoring