How to test websocket latency
Send timestamped frames over one open socket and record every round trip, then read the distribution rather than the average. On loopback, 200 echo round trips gave a median of 0.17 ms and a p95 of 0.45 ms. The slowest frame was the first, at 2.08 ms.
Why check this
Run this when a realtime feature has a responsiveness budget: a cursor position shared between editors, a price feed, a match that has to look live. Run it again after the socket moves behind a proxy, a load balancer or a TLS terminator, because each of those adds a hop the application code cannot see.
The failure it prevents is a number that hides its own tail. A team that reports one average for socket latency ships a feature where one update in twenty takes several times longer than the figure in the report, and the users describe it as stutter rather than as slowness. The distribution shows that gap. The average removes it.
Prerequisites
- Node 22 and
ws8 in an empty directory:npm i ws@8. The ws API documentation coverspingand thepongevent used in step 3. - A free port.
netstat -ano | grep 49317prints nothing when 49317 is free. - Every number on this page was measured on loopback on one Windows machine on 2026-09-12. There is no network in these figures. They are a floor for what the server itself costs, and nothing else: a real client adds the path between the two machines on top.
Save this as echo-server.mjs. It answers every frame with the same bytes, so the round trip measures the server and not the work behind it.
// node echo-server.mjs listens on ws://127.0.0.1:49317
import { WebSocketServer } from 'ws';
const PORT = 49317;
const MAX_PAYLOAD = 8192;
const wss = new WebSocketServer({ port: PORT, maxPayload: MAX_PAYLOAD });
let open = 0;
wss.on('connection', (ws) => {
open += 1;
ws.on('message', (data, isBinary) => ws.send(data, { binary: isBinary }));
ws.on('error', (err) => console.log(`server socket error: ${err.message}`));
ws.on('close', (code) => {
open -= 1;
console.log(`closed ${code}, open ${open}`);
});
});
wss.on('listening', () =>
console.log(`echo listening on ${PORT}, maxPayload ${MAX_PAYLOAD} bytes`));
Save this as latency.mjs. It keeps one socket open, sends the next frame only after the previous one returns, and prints the distribution.
// node latency.mjs
import WebSocket from 'ws';
const N = 200;
const ws = new WebSocket('ws://127.0.0.1:49317');
const rtt = [];
let sent = 0;
let t0 = 0n;
const q = (a, p) => a[Math.min(a.length - 1, Math.ceil((p / 100) * a.length) - 1)];
const ms = (n) => (Number(n) / 1e6).toFixed(2);
function shoot() {
t0 = process.hrtime.bigint();
ws.send(String(sent));
}
ws.on('open', shoot);
ws.on('message', () => {
rtt.push(process.hrtime.bigint() - t0);
sent += 1;
if (sent < N) return shoot();
const a = [...rtt].sort((x, y) => (x < y ? -1 : 1));
const mean = a.reduce((s, v) => s + v, 0n) / BigInt(a.length);
console.log(`frames ${a.length}`);
console.log(`first ${ms(rtt[0])} ms`);
console.log(`min ${ms(a[0])} ms`);
console.log(`mean ${ms(mean)} ms`);
console.log(`p50 ${ms(q(a, 50))} ms`);
console.log(`p95 ${ms(q(a, 95))} ms`);
console.log(`p99 ${ms(q(a, 99))} ms`);
console.log(`max ${ms(a[a.length - 1])} ms`);
ws.close(1000);
});
Steps
- Step 1.
Start the echo server.
node echo-server.mjs > server.log 2>&1 &echo listening on 49317, maxPayload 8192 bytes - Step 2.
Measure 200 round trips on one connection and read the whole distribution.
node latency.mjsframes 200 first 2.08 ms min 0.10 ms mean 0.21 ms p50 0.17 ms p95 0.45 ms p99 1.05 ms max 2.08 msRead it downward. The maximum and the first frame are the same value, so the slowest round trip of the run was the opening one. The mean, 0.21 ms, sits above the median, 0.17 ms, because a handful of frames past p95 pull it up. The p99 at 1.05 ms is six times the median. One number cannot carry that shape.
- Step 3.
Measure the protocol round trip on the same server, using a ping frame instead of an application message.
// node ping-latency.mjs import WebSocket from 'ws'; const N = 200; const ws = new WebSocket('ws://127.0.0.1:49317'); const rtt = []; let n = 0; let t0 = 0n; const q = (a, p) => a[Math.min(a.length - 1, Math.ceil((p / 100) * a.length) - 1)]; const ms = (v) => (Number(v) / 1e6).toFixed(2); function shoot() { t0 = process.hrtime.bigint(); ws.ping(); } ws.on('open', shoot); ws.on('pong', () => { rtt.push(process.hrtime.bigint() - t0); n += 1; if (n < N) return shoot(); const a = [...rtt].sort((x, y) => (x < y ? -1 : 1)); const mean = a.reduce((s, v) => s + v, 0n) / BigInt(a.length); console.log(`pings ${a.length}`); console.log(`first ${ms(rtt[0])} ms`); console.log(`min ${ms(a[0])} ms`); console.log(`mean ${ms(mean)} ms`); console.log(`p50 ${ms(q(a, 50))} ms`); console.log(`p95 ${ms(q(a, 95))} ms`); console.log(`max ${ms(a[a.length - 1])} ms`); ws.close(1000); });pings 200 first 2.02 ms min 0.06 ms mean 0.13 ms p50 0.10 ms p95 0.20 ms max 2.02 msA pong is answered inside
wswith no application code in the path. The difference at the median, 0.17 ms against 0.10 ms, is what the echo handler cost. Subtracting the two separates transport from handler without touching the server. - Step 4.
Repeat the measurement at three payload sizes to see what the bytes cost.
// node latency-size.mjs <bytes> import WebSocket from 'ws'; const N = 200; const size = Number(process.argv[2]); const payload = 'x'.repeat(size); const ws = new WebSocket('ws://127.0.0.1:49317'); const rtt = []; let t0 = 0n; const q = (a, p) => a[Math.min(a.length - 1, Math.ceil((p / 100) * a.length) - 1)]; const ms = (v) => (Number(v) / 1e6).toFixed(2); const shoot = () => { t0 = process.hrtime.bigint(); ws.send(payload); }; ws.on('open', shoot); ws.on('message', () => { rtt.push(process.hrtime.bigint() - t0); if (rtt.length < N) return shoot(); const a = [...rtt].sort((x, y) => (x < y ? -1 : 1)); console.log(`${String(size).padStart(5)} bytes p50 ${ms(q(a, 50))} ms p95 ${ms(q(a, 95))} ms`); ws.close(1000); });for s in 8 1024 8192; do node latency-size.mjs $s; done8 bytes p50 0.12 ms p95 0.28 ms 1024 bytes p50 0.15 ms p95 0.35 ms 8192 bytes p50 0.21 ms p95 0.44 msThe median rises by 0.09 ms across a thousandfold change in payload. Compare that against step 2, where frames of three bytes or fewer measured 0.17 ms at the median in a different run of the same script. The gap between two runs is close to the gap between the smallest and largest payload, so one run of each size does not establish a payload effect.
How to read the result
| What you see | What it means | What to do | | --- | --- | --- | | mean above p50 | A minority of slow frames is pulling the average up | Report p50 and p95. Keep the mean out of the budget. | | max equals the first frame | The slowest round trip was the warm-up | Report it separately, or discard the first frames and say how many. | | p99 several times p50 | The tail is long even with no network | Look at garbage collection, the event loop and the handler, not the wire. | | p95 flat as payload grows | Serialisation is not the cost at these sizes | Spend the effort on the handler, not on a smaller message format. | | Every figure under 1 ms | You are measuring loopback | Repeat from a second machine before quoting the number to anyone. |
Common mistakes
Thresholds
Treat these as the floor of the measurement, not as a target. They contain no network, no TLS and no application work beyond an echo. Any real deployment adds to all four numbers, and the p95 usually grows faster than the median.
Source: Measured by latency.mjs on Node 22.23.2 with ws 8.21.3 on 2026-09-12What to check next
- How to check p95 latency: why the percentile and not the average, with the same argument applied to HTTP.
- How to check websocket ping pong: the ping frame used in step 3, and what a missing pong means for a connection.
- How to check websocket message order: a slow handler reorders responses as well as delaying them.
- Load test ramp up time: the warm-up effect that made the first frame the slowest one here.
FAQ
What is a good websocket latency?
The number that fits the feature's budget, measured at p95 from where the users are. The figures here are loopback and have no network in them, so they set a floor for the server's own work rather than a target.
How do I measure latency without changing the server?
Use a ping frame, as in step 3. ws answers a ping with a pong inside the library, so the round trip crosses the transport and no application code. Any server that follows RFC 6455 replies.
Should I measure with one connection or many?
One connection for latency, because reopening the socket measures the handshake instead. Concurrency belongs in a separate run where you hold many sockets open and watch what the same percentile does.
Why is the first message so much slower?
Connection setup, first allocations and code that has not been optimised yet all land on it. The first round trip was 2.08 ms here against a median of 0.17 ms, and it was the slowest frame of 200.
Does message size change the latency?
Across 8 to 8192 bytes the median moved 0.09 ms on loopback, which is close to the difference between two runs of the same script. Establish the run-to-run spread first, then decide whether a size effect is real.
Verified
Verified by Maks VernyNode 22.23.2ws 8.21.3
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
intermediate10 minpublished updated Maks Verny