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.

Phases of a realistic WebSocket load test A realistic WebSocket load test ramps connections for ten minutes, holds them while broadcasting and measuring for about ten minutes, then triggers a reconnect storm with a server restart and measures recovery. Phases of a realistic WebSocket load test hold and measure ramp connections (0 min) target concurrency reached (10 min) steady: idle + broadcasts (12 min) reconnect storm (server restart) (22 min) recovered, measure again (26 min) The hold phase finds memory and fan-out limits; the storm phase finds accept and backoff limits
Ramp, hold, storm — each phase finds a different limit.

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.

Expected server heap across the ramp Estimated heap for 5,000 to 100,000 concurrent sockets at 34 kilobytes per socket plus 8 kilobytes of application state, against a 2048 megabyte pod limit. Expected server heap across the ramp 34 KB socket + 8 KB app state each — dashed line is a 2048 MB pod limit Socket + buffers App state 2.5k 103 MB 5.0k 205 MB 332 MB 10k 410 MB 664 MB 20k 820 MB 1.3k MB 313 MB 40k 1.6k MB 2048 MB pod limit
Compare the measured heap during the hold phase with the per-connection estimate; a steady climb at constant connections is a leak, not load.

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.

What each measurement tells you Handshake time from k6 reveals accept, TLS and auth limits; custom delivery latency reveals fan-out and event-loop limits; unexpected closes reveal timeouts and crashes; server heap and event-loop lag reveal memory and CPU limits. What each measurement tells you Source Finds ws_connecting p95 k6 built-in accept / TLS / auth limits ws_delivery_latency custom Trend fan-out and event-loop limits unexpected closes custom Counter timeouts, evictions, crashes server heap / RSS server metrics memory per connection, leaks event-loop lag server metrics CPU saturation Client-side and server-side metrics must be read together to locate the bottleneck
k6 measures what users feel; server metrics explain why.

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.

Back to Load Testing & Capacity Planning.