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

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

  1. Step 1.

    Start the echo server.

    node echo-server.mjs > server.log 2>&1 &
    
    echo listening on 49317, maxPayload 8192 bytes
  2. Step 2.

    Measure 200 round trips on one connection and read the whole distribution.

    node latency.mjs
    
    frames      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 ms

    Read 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.

  3. 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 ms

    A pong is answered inside ws with 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.

  4. 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; done
    
        8 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 ms

    The 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

Sign: The report carries one average and the feature still feels uneven in use.Cause: In the run above the mean is 0.21 ms and the median 0.17 ms, while p99 is 1.05 ms. An average sits between the common case and the tail and describes neither. A latency budget needs a percentile and the percentile has to be named.
Sign: The first measurement is much slower than the rest and gets averaged in.Cause: The opening round trip carries connection setup, the first buffer allocations and code that has not been optimised yet. It was the maximum of the whole run here, 2.08 ms against a 0.17 ms median. Warm up, or report the first frame as its own number.
Sign: Each measurement opens a new socket, and latency looks an order of magnitude worse.Cause: That measures the TCP handshake and the HTTP upgrade, not message latency. The point of a socket is that those costs are paid once. Keep one connection open for the whole run, as latency.mjs does.
Sign: A loopback figure is quoted as the latency users will see.Cause: Loopback has no network in it. These numbers bound the server's own work from below and say nothing about the path to a browser. Run the same client from another machine, or from a throttled browser profile, before the number leaves the team.

Thresholds

p50 0.17 ms, p95 0.45 ms, p99 1.05 ms, max 2.08 ms over 200 echo round trips on loopback

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-12

What to check next

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.

intermediate10 minpublished updated Maks Verny