How to check websocket ping pong

Point a ws client at a server that calls ws.ping() and log the control frames. node wsping.mjs "ws://127.0.0.1:9481/ping?every=1000" prints PING frame from server once a second and PONG frame from server after 2 ms. Browser code sees none of it, because the browser API exposes neither frame.

Why check this

Run this when you add or change a heartbeat, when a proxy is put in front of the socket, and whenever connections are reported as dropping after a period of quiet. The heartbeat keeps an idle socket from being collected as dead, so one that is not running fails minutes later, far from its cause.

The failure it prevents is a heartbeat that was written in the front end and does nothing. Ping and pong are control frames in the protocol, and the browser WebSocket object has no method to send one and no event to receive one. A team can ship ws.ping() in a React hook, wrap the throw in a try block, and believe the connection is kept alive for a year.

The check tells you which side sends pings, at what interval, and whether the peer answers. It says nothing about an intermediary that drops control frames, which needs the same test run through the proxy.

Prerequisites

// wsprobe.mjs - one target for every WebSocket lifecycle check. node wsprobe.mjs 9481
import { WebSocketServer } from 'ws';

const port = Number(process.argv[2] ?? 9481);
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const wss = new WebSocketServer({ port }, () => log('listening on', port));

wss.on('connection', (ws, req) => {
  const url = new URL(req.url, 'http://127.0.0.1');
  const arg = (k, d) => Number(url.searchParams.get(k) ?? d);
  log('open', req.url);
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; log('pong from client'); });
  ws.on('message', (d) => { log('recv', JSON.stringify(String(d))); if (url.pathname === '/echo') ws.send(String(d)); });
  ws.on('close', (c, r) => log('server close event', c, JSON.stringify(String(r))));

  if (url.pathname === '/close') {
    setTimeout(() => { log('ws.close(', arg('code', 1000), ')'); ws.close(arg('code', 1000), url.searchParams.get('reason') ?? ''); }, arg('after', 300));
  }
  if (url.pathname === '/crash') {
    setTimeout(() => { log('ws.terminate(), no close frame'); ws.terminate(); }, arg('after', 300));
  }
  if (url.pathname === '/ping') {
    const t = setInterval(() => { log('ws.ping()'); ws.ping(); }, arg('every', 1000));
    ws.on('close', () => clearInterval(t));
  }
  if (url.pathname === '/idle') {
    const every = arg('every', 3000);
    const t = setInterval(() => {
      if (ws.isAlive === false) { log('no pong since last sweep, terminate'); clearInterval(t); return ws.terminate(); }
      ws.isAlive = false; log('sweep: ws.ping()'); ws.ping();
    }, every);
    ws.on('close', () => clearInterval(t));
  }
});
// wsping.mjs - ws 8 client. Control frames are events here and nowhere else.
// node wsping.mjs "ws://127.0.0.1:9481/ping?every=1000" 4000
import WebSocket from 'ws';
const t0 = Date.now();
const at = (...a) => console.log(String(Date.now() - t0).padStart(6), 'ms', ...a);
const ws = new WebSocket(process.argv[2]);
let sent = 0;
ws.on('open', () => { at('open'); sent = Date.now(); ws.ping('rtt'); at('ws.ping("rtt") sent'); });
ws.on('ping', (d) => at('PING frame from server, payload', JSON.stringify(String(d))));
ws.on('pong', (d) => at('PONG frame from server after', Date.now() - sent, 'ms, payload', JSON.stringify(String(d))));
ws.on('message', (d) => at('text message', JSON.stringify(String(d))));
ws.on('close', (c) => { at('close', c); process.exit(0); });
setTimeout(() => ws.close(1000, 'done'), Number(process.argv[3] ?? 4000));
// rawframes.mjs - handshake by hand, optionally send one masked text frame, hexdump the rest.
// node rawframes.mjs 9481 "/ping?every=1000" 4000 ['{"type":"ping"}']
import net from 'node:net';
import crypto from 'node:crypto';
const [port, path, ms, text] = process.argv.slice(2);
const t0 = Date.now();
const at = (...a) => console.log(String(Date.now() - t0).padStart(6), 'ms', ...a);
const key = crypto.randomBytes(16).toString('base64');
const s = net.connect(Number(port), '127.0.0.1', () => s.write(
  `GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`));
const hex = (b) => b.toString('hex').replace(/(..)/g, '$1 ').trim();
let head = false;
s.on('data', (b) => {
  if (!head) { at(b.subarray(0, b.indexOf('\r\n')).toString()); head = true; b = b.subarray(b.indexOf('\r\n\r\n') + 4); if (!b.length) return; }
  at('in ', hex(b));
});
if (text) setTimeout(() => {
  const p = Buffer.from(text), m = crypto.randomBytes(4), out = Buffer.concat([Buffer.from([0x81, 0x80 | p.length]), m, p.map((c, i) => c ^ m[i % 4])]);
  at('out', hex(out)); s.write(out);
}, 500);
setTimeout(() => process.exit(0), Number(ms));

Steps

  1. Step 1.

    Start the target in its own terminal and leave it running.

    node wsprobe.mjs 9481
    
    22:40:54.395 listening on 9481
  2. Step 2.

    Connect the client that can see control frames and watch both directions for three and a half seconds.

    node wsping.mjs "ws://127.0.0.1:9481/ping?every=1000" 3500
    
        18 ms open
      21 ms ws.ping("rtt") sent
      22 ms PONG frame from server after 2 ms, payload "rtt"
    1029 ms PING frame from server, payload ""
    2032 ms PING frame from server, payload ""
    3039 ms PING frame from server, payload ""
    3515 ms close 1000

    The pong came back in 2 ms carrying rtt, the payload the ping was sent with. A pong echoes the ping payload, which is what matches one to the other on a busy socket. The 2 ms is loopback with no network in it, so read it as a floor, not a latency figure.

  3. Step 3.

    Read the server terminal from step 1 for the same connection.

    22:54:27.497 open /ping?every=1000
    22:54:28.512 ws.ping()
    22:54:28.513 pong from client
    22:54:29.516 ws.ping()
    22:54:29.516 pong from client
    22:54:30.522 ws.ping()
    22:54:30.523 pong from client
    22:54:30.998 server close event 1000 "done"

    Three pings, three pongs, each answered in a millisecond. Nothing in wsping.mjs replies to a ping. The library does it inside the receiver, so a pong proves the peer is reading its socket, not that its application code is alive.

  4. Step 4.

    Run the same endpoint against the browser API and count the events the client code gets.

    node -e "const w=new WebSocket('ws://127.0.0.1:9481/ping?every=1000');const e=[];for(const n of ['open','message','error','close'])w.addEventListener(n,()=>e.push(n));setTimeout(()=>{console.log('client events in 4 s:',e.join(', '));process.exit(0)},4000)"
    
    client events in 4 s: open

    The server logged three pings and three pongs for that same four seconds:

    22:54:32.220 open /ping?every=1000
    22:54:33.241 ws.ping()
    22:54:33.243 pong from client
    22:54:34.243 ws.ping()
    22:54:34.243 pong from client
    22:54:35.254 ws.ping()
    22:54:35.255 pong from client
    22:54:36.249 server close event 1006 ""

    The client answered every ping and its own code saw one event.

  5. Step 5.

    Look at what a ping is on the wire.

    node rawframes.mjs 9481 "/ping?every=1000" 2600
    
         7 ms HTTP/1.1 101 Switching Protocols
    1015 ms in  89 00
    2028 ms in  89 00

    89 is FIN plus opcode 9, a ping. 00 is a zero-length payload. Two bytes, no application data, nothing onmessage will ever be given.

  6. Step 6.

    Send an application-level heartbeat over the same server and compare the bytes.

    node rawframes.mjs 9481 "/echo" 1500 '{"type":"ping"}'
    
         6 ms HTTP/1.1 101 Switching Protocols
     517 ms out 81 8f 00 9b b1 68 7b b9 c5 11 70 fe 93 52 22 eb d8 06 67 b9 cc
     518 ms in  81 0f 7b 22 74 79 70 65 22 3a 22 70 69 6e 67 22 7d

    Opcode 1, a text frame, 15 bytes of payload. The outbound copy is masked, which is why its bytes are unreadable and the reply is not. A different mechanism from step 5 with the same name.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | PING frame from server at a steady interval | The server drives the heartbeat and the library answers | Nothing. Record the interval for the timeout check. | | pong from client in the server log | The peer's socket is being read | Treat as transport liveness only. A blocked event loop still pongs for a while. | | A ping with no pong within one interval | The peer is gone or stalled | Expect a close soon. See Websocket connection timeout. | | 89 00 in a hexdump | A control frame, opcode 9 | Correct. Application code cannot observe this frame. | | 81 … carrying {"type":"ping"} | An application message named ping | Fine, but it is not a control frame and no library answers it for you. |

Common mistakes

Sign: A browser heartbeat calls ws.ping() and the connection still dies quietly.Cause: The browser WebSocket API has no ping method and no ping or pong event. Only the server, or a Node client using a library, can send a control frame. A heartbeat in page code has to be an ordinary message that the server answers on purpose.
Sign: Pongs keep arriving while the application has stopped responding to messages.Cause: The pong is generated inside the library's receiver, as step 3 shows, before any handler of yours runs. A socket that pongs proves the process is reading its socket, not that it can still serve a request.
Sign: Two implementations disagree about whether the heartbeat is working.Cause: One is counting control frames and the other is counting application messages called ping. They share a name and nothing else: opcode 9 against opcode 1, as steps 5 and 6 print side by side.
Sign: A ping is sent with a payload and the matching pong cannot be found.Cause: A pong must carry the ping's payload back, so the match is on bytes, not on order. A library that sends pings with an empty payload, as wsprobe.mjs does, gives you nothing to match on when several are in flight.

What to check next

FAQ

What websocket ping pong interval should I use?

Shorter than the shortest idle timeout on the path, load balancer and reverse proxy included, and long enough not to wake a mobile radio for nothing. Measure the timeout first, then halve it.

Is a websocket heartbeat built in?

No. Nothing in the protocol or in ws sends a ping on its own. The interval in step 2 exists because wsprobe.mjs sets it. An unconfigured server sends no control frames at all.

Should the client or the server send the ping?

The server, when you own it. It knows its own timeout and can terminate the connections that went quiet. A client-driven heartbeat works too, but in a browser it cannot be a control frame, so it costs an application message each way.

Can I see ping and pong frames in Chrome DevTools?

Not in Chrome 152. Driving it while the server pinged three times produced one frame event, for the text message the page sent, and none for a ping or a pong. The Messages panel lists data frames only.

Verified

Verified by Maks Vernynode 22.23.2ws 8.21.3Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate7 minpublished updated Maks Verny