Load testing WebSockets with k6 #
Your HTTP load tests are thorough, and your WebSocket server has never been tested beyond a developer opening twenty tabs. Then launch day brings 30,000 concurrent users, the server holds them fine, but broadcast latency climbs to eight seconds and nobody knows whether the limit is CPU, the Redis fan-out, or the event loop. A WebSocket load test is not an HTTP load test with a different URL: the interesting load is not requests per second but concurrent connections, messages per second fanned out to them, and what happens when they all reconnect at once. k6 supports WebSockets natively and scripts in JavaScript, which makes it a good fit for modelling those behaviours and asserting on them with thresholds that fail a CI run.
Root cause #
Most “WebSocket load tests” measure the wrong thing. They open connections as fast as possible, send a message, and report handshake throughput — which tells you how fast the server accepts sockets, not how it behaves holding them. Real clients connect over minutes, then mostly sit idle, receiving broadcasts and occasionally sending. The resource profile of that workload — memory per connection, timer and heartbeat overhead, fan-out cost per message — only appears once the connections are held for a sustained period.
Latency is also usually measured wrongly. The number users feel is publish-to-receive latency: the time from a message being sent by one client (or the server) to it being delivered to every other subscriber. That requires timestamps in the payload and a metric computed by the receiving virtual user, not the handshake time k6 reports by default.
Resolution #
The script below models a chat-like workload. Each virtual user connects with a ticket, joins a room, stays connected for the test’s duration, answers the server’s heartbeat implicitly (k6 responds to pings automatically), sends a message every so often with a timestamp, and records the latency of messages it receives from others. Thresholds turn the test into a pass/fail gate.
// chat-load.js — run: k6 run -e WS_URL=wss://staging.example.com/ws chat-load.js
import { WebSocket } from 'k6/websockets';
import { Trend, Counter } from 'k6/metrics';
import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
const deliveryLatency = new Trend('ws_delivery_latency', true); // ms, publish → receive
const received = new Counter('ws_messages_received');
const unexpectedCloses = new Counter('ws_unexpected_closes');
const ROOMS = 200; // ~50 users per room at 10k VUs
const SEND_EVERY_MS = [20_000, 60_000]; // each user posts every 20–60 s
const HOLD_MS = 15 * 60 * 1000; // keep each connection 15 minutes
export const options = {
scenarios: {
chat: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '10m', target: 10000 }, // gradual ramp, like a real morning
{ duration: '15m', target: 10000 }, // hold
{ duration: '2m', target: 0 },
],
gracefulRampDown: '30s',
},
},
thresholds: {
ws_delivery_latency: ['p(95)<250', 'p(99)<800'], // fail the run on slow fan-out
ws_unexpected_closes: ['count<50'],
ws_connecting: ['p(95)<1000'], // handshake time (built-in metric)
},
};
export default function () {
const room = `room-${__VU % ROOMS}`;
const ws = new WebSocket(`${__ENV.WS_URL}?room=${room}&ticket=${__ENV.TICKET ?? 'loadtest'}`);
let sendTimer;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'room.join', room }));
const schedule = () => {
sendTimer = setTimeout(() => {
ws.send(JSON.stringify({ type: 'chat.send', room, sentAt: Date.now(), from: __VU }));
schedule();
}, randomIntBetween(...SEND_EVERY_MS));
};
schedule();
setTimeout(() => ws.close(), HOLD_MS); // bounded lifetime per VU iteration
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'chat.message' && msg.from !== __VU && msg.sentAt) {
deliveryLatency.add(Date.now() - msg.sentAt); // needs synced clocks: see note
received.add(1);
}
};
ws.onclose = (e) => {
clearTimeout(sendTimer);
if (e.code !== 1000 && e.code !== 1005) unexpectedCloses.add(1);
};
}
sentAt is stamped by the sending virtual user and read by receiving ones, so latency is measured on the load generator’s clock at both ends — accurate as long as senders and receivers run on the same machine or on machines with synchronized clocks. For distributed runs, have the server stamp messages at publish time instead and measure server-to-receiver latency.
Run the load generator close to the target (same region) so network latency does not dominate, and watch the generator itself: a single k6 instance can hold tens of thousands of WebSocket connections, but it needs raised descriptor limits and enough CPU, exactly like the server. The OS side is covered in raising file descriptor limits for WebSockets.
Storm scenario #
The most valuable test is often the unpleasant one: hold the target connections, restart a server node (or all of them), and measure how long the fleet takes to recover. Add a second scenario that runs in parallel and triggers the restart, or do it by hand while the hold phase runs. The client logic in the script must include the same reconnect behaviour as production — jittered backoff — or the test measures your load tool, not your system:
// Reconnect with full jitter inside a VU, mirroring the production client.
function connectWithBackoff(url, attempt = 0) {
const ws = new WebSocket(url);
ws.onclose = (e) => {
if (e.code === 1000) return;
const cap = Math.min(30000, 500 * 2 ** attempt);
setTimeout(() => connectWithBackoff(url, attempt + 1), Math.random() * cap);
};
ws.onopen = () => { attempt = 0; };
return ws;
}
Pass criteria for the storm: all virtual users reconnected within your target window, handshake rejections limited to what your admission control intends, and delivery latency back under threshold within a minute of recovery.
Verification #
A load test is only useful if it can fail. Before trusting the suite, sabotage the server deliberately — add a 50 ms synchronous delay in the broadcast path, or cut its memory limit — and confirm the thresholds fail the run. Then keep the test in CI or a nightly job at a reduced scale, with results exported for comparison over time:
k6 run --out json=results.json -e WS_URL=wss://staging.example.com/ws chat-load.js
# Extract p95 delivery latency from the summary for trend tracking.
jq -r 'select(.type=="Point" and .metric=="ws_delivery_latency") | .data.value' results.json | \
sort -n | awk '{a[NR]=$1} END {print "p95:", a[int(NR*0.95)]}'
A regression in p95 delivery latency or in unexpected closes between two builds is the earliest warning you will get before users notice.
Operational checklist #
FAQ #
Can k6 load test WebSockets? #
Yes. k6 has a WebSocket API (k6/websockets) for opening connections, sending and receiving messages, and handling close events inside virtual users, plus built-in metrics for connection timing and custom metrics for anything else.
How many WebSocket connections can one k6 instance hold? #
Tens of thousands on a well-provisioned machine with raised file-descriptor limits, depending on message rate and script complexity. For larger tests, run several instances or k6’s distributed options.
How do I measure broadcast latency in k6? #
Put a send timestamp in each message and record Date.now() - sentAt in the receiving virtual users with a custom Trend metric. Keep senders and receivers on synchronized clocks, or have the server stamp messages.
Should load tests use production-like reconnect logic? #
Yes. Without backoff and jitter, virtual users reconnect in lockstep and produce a storm far worse than real clients would, so the test measures an unrealistic scenario.
Related #
- Load Testing WebSockets with Artillery — a YAML-driven alternative.
- Benchmarking Node.js WebSocket Servers — isolating server library performance.
- Capacity Planning for WebSocket Fleets — turning results into node counts.
- Exponential Backoff with Jitter for WebSocket Reconnects — the client behaviour to mirror.
Back to Load Testing & Capacity Planning.