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
- Node 22 or later. Its global
WebSocketis the browser API. Thewspackage adds a client that exposes control frames. npm i ws@8in an empty directory. See ws on ping and pong.- RFC 6455 section 5.5.2 for the ping frame, and MDN on WebSocket to confirm what the browser API does not have.
- A free port. Check with
netstat -ano | grep 9481first and pick another number if anything answers. - The target server,
wsprobe.mjs. Its/pingpath sends a ping on a fixed interval and logs every pong it gets back.
// 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));
}
});
- A client that can see control frames,
wsping.mjs. Note that it never writes a pong: the library answers for it.
// 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));
- A wire reader,
rawframes.mjs. It hexdumps the frames, which is how a ping is told from an application message called one.
// 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
- Step 1.
Start the target in its own terminal and leave it running.
node wsprobe.mjs 948122:40:54.395 listening on 9481 - 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" 350018 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 1000The 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. - 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.mjsreplies 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. - 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: openThe 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.
- Step 5.
Look at what a ping is on the wire.
node rawframes.mjs 9481 "/ping?every=1000" 26007 ms HTTP/1.1 101 Switching Protocols 1015 ms in 89 00 2028 ms in 89 0089is FIN plus opcode 9, a ping.00is a zero-length payload. Two bytes, no application data, nothingonmessagewill ever be given. - 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 7dOpcode 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
What to check next
- Websocket connection timeout: the ping interval and the timeout are one setting, measured together.
- Websocket close code 1006: a missed pong ends as a terminated socket with no close frame.
- How to test websocket reconnect: what the client does after the heartbeat fails.
- How to test websocket latency: the pong round trip is the cheapest latency probe you have.
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.
Related on this site
intermediate7 minpublished updated Maks Verny