Load Testing and Capacity Planning
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
A real-time system does not fail like a request-response one. A REST endpoint under too much load answers slowly; a WebSocket server under too much load keeps every connection open and delivers every message late, then drops connections all at once when a buffer, a file-descriptor limit or a load balancer timeout is reached. This page shows how to generate that load, what to measure while it runs, and how to turn the numbers into "this many nodes for this many users". The tools are k6, Artillery and JMeter for WebSocket and Socket.IO traffic, and headless Chrome with fake media devices (plus a few purpose-built tools) for WebRTC.
The page was written against k6 2.3.0 (grafana/k6 Docker image) and Artillery 2.0.34 (artilleryio/artillery image,
npm 2026-10-19), Playwright 1.64.1 driving Google Chrome with fake media devices, ws 8.22.0 and Socket.IO 4.8.4 for
the server under test, and the JMeter WebSocket Samplers plugin 1.3.2. Everything was checked on 2026-10-24; the k6,
Artillery and Playwright examples were run against a small local Chatterbox server, which is deliberately simple so that
the numbers show the method rather than a production result. The server stacks themselves are on
Socket.IO, uWebSockets.js,
Spring WebFlux, SignalR
and the others of the section; the cluster side of the story (file descriptors, backplanes, draining) is on
Scaling WebSockets, and the media side on
Scaling WebRTC and TURN.
Which scenarios use it
-
Chatterbox needs two different tests: a connection test (how many idle-but-live sockets a node holds, how fast a crowd can connect, what a reconnection storm does) and a traffic test (messages per second and the delay from one user’s message to the last recipient’s copy of it).
-
Pixel Arena is dominated by tick rate: a 20 Hz room sends 20 snapshots per second to every player, so the load test is about CPU per room and the jitter of the tick loop (see Multiplayer Game Architecture).
-
Huddle has two bottlenecks: the signalling server (a Chatterbox-like problem, but with bursts of offers, answers and ICE candidates when a meeting starts) and the media server, whose load is tracks times subscribers and is only measurable with real media.
What to measure
A WebSocket load test is only as useful as the numbers collected on both sides. The load generator sees the user’s view; the server must be sampled separately for the resource view.
| Metric | Where it comes from | What a bad value means |
|---|---|---|
Connection setup time |
k6 |
The accept queue, TLS or the proxy is saturated |
Handshake failures |
Errors on connect, close codes 1006 and 4xxx |
A limit was hit: file descriptors, |
Ack round trip |
Client-side time from sending an envelope to its ack |
The event loop (or a database behind the handler) is slow |
Fan-out latency |
Client-side time from the sender’s stamp to the receiver’s copy |
The server cannot deliver one message to many sockets fast enough |
Messages per second in and out |
|
Throughput ceiling; the ratio out/in is the average fan-out |
Memory per connection |
Server RSS divided by live connections |
Linear growth that does not stop after the ramp is a leak |
Event-loop delay or GC pause |
|
Latency spikes that no average shows |
CPU per core |
OS metrics |
Above about 70 percent sustained there is no room for a spike |
Reconnect rate and close codes |
Server logs and metrics |
A storm after a deploy or a proxy restart |
Call quality |
|
Media server or network saturation (WebRTC only) |
Use percentiles, not averages: fan-out latency of 3 ms on average can hide a p99 of 800 ms. The metrics to export continuously in production are on Observability; during a test, sample the same ones every few seconds.
Workload models and coordinated omission
A load model decides when a new virtual user starts.
-
A closed model has a fixed number of users; a user starts its next iteration when the previous one finishes. If the server slows down, the arrival rate silently falls, and the test under-reports the damage (coordinated omission).
-
An open model starts users at a given rate whatever the server does, which is what the real world does: people open the app regardless of how the server feels. For connection tests this is the right model.
For long-lived connections the two are linked by Little’s law: concurrent connections equal the arrival rate times the
average session duration. A rate of 50 new connections per second with an average session of 10 minutes is 30,000
concurrent connections, and that is how a plan expressed in "daily active users" becomes a test parameter. The
JMeter section explains the same distinction in
Open Model & Throughput Control; in k6 it is the choice
between the ramping-vus executor (closed) and ramping-arrival-rate (open).
The system under test
All the examples below talk to one small Node.js server that speaks a trimmed-down Chatterbox v1 protocol
(hello, join, message and presence, with acks; the full protocol is on
Message Design and Serialization) on /ws, and a
Socket.IO 4.8 endpoint (join and message events with acknowledgements) on the same HTTP port. Authentication is left
out so that the generator does not depend on a ticket service; a real test mints one-time tickets with the same code the
application uses (see Authentication and Authorization).
The server also exposes a /stats endpoint with the resource view:
import { createServer } from 'node:http';
import { monitorEventLoopDelay } from 'node:perf_hooks';
const loop = monitorEventLoopDelay({ resolution: 10 }); // samples the event loop every 10 ms
loop.enable();
const http = createServer((req, res) => {
if (req.url === '/stats') {
const m = process.memoryUsage();
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
rssMb: Math.round(m.rss / 1048576),
loopP99Ms: loop.percentile(99) / 1e6, // nanoseconds to milliseconds
connections: wss.clients.size + io.engine.clientsCount,
}));
loop.reset(); // each sample covers the time since the last one
return;
}
res.statusCode = 404;
res.end();
});
Run the server on the machine under test and the generator on another one. When both run on a laptop, the generator and the server compete for the same cores and the result describes the laptop.
k6
k6 is a load generator whose scripts are JavaScript and whose engine is written in Go. Each virtual user (VU) runs the default function in its own JavaScript runtime, which makes VUs heavier than the goroutines of a Go tool: the k6 documentation estimates 1 to 5 MB per VU for simple scripts. For WebSocket tests there are two modules:
-
k6/websocketsis the recommended one. It follows the browser WebSocket API (new WebSocket(url, protocols, params),addEventListener,onmessage,send,close) and runs on a global event loop, so the script looks like browser code andsetIntervalandsetTimeoutare the global functions. -
k6/wsis the older callback API (ws.connect(url, params, callback)), still supported. -
k6/experimental/websocketsis deprecated.
The module emits six built-in metrics: ws_connecting (trend, handshake time), ws_msgs_sent and ws_msgs_received
(counters), ws_ping (trend), ws_session_duration (trend) and ws_sessions (counter). Everything application-level,
such as an ack round trip, is a custom metric that the script adds.
A Chatterbox scenario
The script below connects, completes hello, joins one of a few rooms, then sends a message every one and a half to
two and a half seconds. It records two custom trends: chatterbox_ack_rtt (envelope sent to ack received) and
chatterbox_fanout_latency (the sender stamps the text with Date.now(), every receiver subtracts it; this is valid
because the generator is one machine with one clock). Thresholds turn the run into a pass or a fail.
import { WebSocket } from 'k6/websockets';
import { Trend, Counter } from 'k6/metrics';
import { check } from 'k6';
const ackRtt = new Trend('chatterbox_ack_rtt', true);
const fanout = new Trend('chatterbox_fanout_latency', true);
const refused = new Counter('chatterbox_handshake_failed');
const TARGET = __ENV.TARGET || 'ws://host.docker.internal:18801/ws';
const ROOMS = Number(__ENV.ROOMS || 5);
const HOLD_MS = Number(__ENV.HOLD_MS || 20000);
export const options = {
scenarios: {
chat: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '10s', target: Number(__ENV.VUS || 50) },
{ duration: '20s', target: Number(__ENV.VUS || 50) },
{ duration: '5s', target: 0 },
],
gracefulRampDown: '30s',
},
},
thresholds: {
chatterbox_ack_rtt: ['p(95)<250'],
chatterbox_fanout_latency: ['p(99)<500'],
chatterbox_handshake_failed: ['count==0'],
checks: ['rate>0.99'],
},
};
let n = 0;
const id = () => `${__VU}-${__ITER}-${++n}`.padEnd(26, '0');
export default function () {
const room = `load-${__VU % ROOMS}`;
const pending = new Map(); // envelope id -> send time
const ws = new WebSocket(TARGET, ['chatterbox.v1']);
let timer;
const send = (type, roomName, payload, extra = {}) => {
const envId = id();
pending.set(envId, Date.now());
ws.send(JSON.stringify({ v: 1, type, id: envId, room: roomName, ts: 0, payload, ...extra }));
return envId;
};
ws.addEventListener('open', () => {
send('hello', null, { client: 'k6/1.0' });
});
ws.addEventListener('message', (e) => {
const m = JSON.parse(e.data);
if (m.type === 'hello') {
send('join', room, { since: null });
} else if (m.type === 'ack') {
const sent = pending.get(m.payload.ref);
if (sent) {
ackRtt.add(Date.now() - sent);
pending.delete(m.payload.ref);
}
check(m, { 'ack ok': (x) => x.payload.ok === true });
if (!timer && m.room === room && m.payload.result.members) {
// joined: start the chat loop with a random 1.5-2.5 s think time
timer = setInterval(() => {
send('message', room, { text: `t=${Date.now()};vu=${__VU}`, replyTo: null, attachments: [] });
}, 1500 + Math.random() * 1000);
}
} else if (m.type === 'message') {
const t = Number(/t=(\d+)/.exec(m.payload.text)[1]);
fanout.add(Date.now() - t);
} else if (m.type === 'error') {
check(m, { 'no protocol error': () => false });
}
});
ws.addEventListener('error', () => refused.add(1));
ws.addEventListener('close', () => clearInterval(timer));
setTimeout(() => ws.close(1000), HOLD_MS);
}
Run it with the official image, pointing TARGET at the node under test (here the host, reached from the container as
host.docker.internal):
docker run --rm --add-host=host.docker.internal:host-gateway \
-v "$PWD":/scripts:ro \
-e VUS=500 -e ROOMS=50 -e HOLD_MS=40000 \
grafana/k6 run /scripts/chatterbox-load.js
The k6 process exits with a non-zero code when a threshold is crossed, which is what a CI job needs. A run with 20 VUs
completed 40 sessions and 437 checks, and ended with chatterbox_ack_rtt p95 of 2 ms, chatterbox_fanout_latency p99
of 3 ms and no failed handshakes, as the summary reported:
chatterbox_ack_rtt............: avg=1.18ms min=0s med=1ms max=5ms p(90)=2ms p(95)=2ms
chatterbox_fanout_latency.....: avg=1.25ms min=0s med=1ms max=5ms p(90)=2ms p(95)=2ms
ws_connecting.................: avg=1.93ms min=795µs med=1.77ms max=4.72ms
ws_msgs_received..............: 1958 39.1/s
ws_msgs_sent..................: 518 10.4/s
ws_sessions...................: 40 0.8/s
A session lasts HOLD_MS and ends by the script closing the socket, and the VU then starts a new iteration, so the
ramping-vus executor keeps the number of live sockets at the stage target. Notice that the script counts a failed
handshake in chatterbox_handshake_failed instead of only relying on checks: a connection that never opens produces no
messages and therefore no failed check.
An open model for connections
For "how fast can a crowd connect" use the ramping-arrival-rate executor: it starts iterations at a rate, independent
of how long the server takes. Each live connection occupies one VU, so preAllocatedVUs and maxVUs must cover the
rate times the session duration (Little’s law again).
export const options = {
scenarios: {
arrivals: {
executor: 'ramping-arrival-rate', // open model: new connections start at a set rate
startRate: 2,
timeUnit: '1s',
stages: [
{ target: 20, duration: '10s' },
{ target: 20, duration: '10s' },
],
preAllocatedVUs: 100,
maxVUs: 300, // each live connection holds one VU
},
},
// thresholds as in the previous script
};
With a 5-second session this ran 308 sessions at about 12 per second and the summary showed dropped_iterations of 1: the
counter that tells you the generator, not the server, could not keep up with the requested rate. Do not add a sleep at
the end of an iteration with this executor; it paces the iterations itself.
Socket.IO from k6
k6 has no Socket.IO client. The Socket.IO protocol is simple enough to speak by hand, and a raw WebSocket to
/socket.io/?EIO=4&transport=websocket skips long-polling altogether (so the test does not need sticky sessions, see
Socket.IO Protocol and Clients for the packet format). A
packet starts with the Engine.IO type (0 open, 2 ping, 3 pong, 4 message) followed, for messages, by the
Socket.IO type (0 CONNECT, 2 EVENT, 3 ACK) and an optional acknowledgement id:
import { WebSocket } from 'k6/websockets';
import { Trend } from 'k6/metrics';
const ackRtt = new Trend('socketio_ack_rtt', true);
const BASE = __ENV.TARGET || 'ws://host.docker.internal:18801';
export const options = { vus: 5, duration: '15s' };
export default function () {
// Engine.IO v4 over a raw WebSocket: no long-polling, so no sticky-session need on the first request
const ws = new WebSocket(`${BASE}/socket.io/?EIO=4&transport=websocket`);
let sentAt = 0;
ws.addEventListener('message', (e) => {
const frame = e.data;
if (frame.startsWith('0')) {
ws.send('40'); // Socket.IO CONNECT to the main namespace
} else if (frame.startsWith('2')) {
ws.send('3'); // Engine.IO ping -> pong
} else if (frame.startsWith('40')) {
ws.send('421["join","load-1"]'); // EVENT with ack id 1
} else if (frame.startsWith('431')) { // ACK for id 1
sentAt = Date.now();
ws.send('422["message","load-1","hello"]');
} else if (frame.startsWith('432')) {
ackRtt.add(Date.now() - sentAt);
}
});
setTimeout(() => ws.close(1000), 10000);
}
The five VUs finished with socketio_ack_rtt of 0.8 ms on average and 1.54 ms at p95. For anything beyond a smoke test
of the raw WebSocket path, Artillery’s Socket.IO engine (next section) is easier to maintain, because it uses the real
socket.io-client, and therefore the real reconnection and transport logic.
Large k6 tests
-
Size the generator. Run 100 VUs on the generator machine, note the memory used and multiply; keep memory below 90 percent of RAM and CPU below 80 percent, or the generator’s own delay is added to every latency in the report.
-
Raise the descriptor limit and the port range. One TCP connection is one descriptor on both sides and one ephemeral port on the generator; a single source address has at most 65,535 ports towards one destination address and port, so tests beyond about 60,000 connections need several generator addresses (or several generator machines).
-
Distribute. k6 can split a run by execution segment, and the k6 Operator runs a test across a Kubernetes cluster (see Custom Resources and Operators). Each instance evaluates thresholds on its own, so aggregate in the metrics backend.
-
Export the time series. Stream the metrics to Prometheus (remote write) or another output next to the server’s own metrics so that both lines share one time axis.
Source: k6: Running large tests
Artillery
Artillery is a Node.js load generator configured in YAML. It has an ws engine (connect, send, think, loop) that
sends frames but does not validate replies, and a socketio engine built on the real socket.io-client that can emit
events, wait for acknowledgements and match responses with JSONPath. For Socket.IO servers the second one is the engine
to use. Its metrics include socketio.emit (counter), socketio.emit_rate and socketio.response_time (histogram, from
emit to response or acknowledgement).
config:
target: "http://host.docker.internal:18801"
phases:
- name: warm up
duration: 10
arrivalRate: 2
rampTo: 10
- name: hold
duration: 20
arrivalRate: 10
socketio:
transports: ["websocket"] # skip long-polling: measures the WebSocket path only
plugins:
ensure:
thresholds:
- socketio.response_time.p95: 250
- socketio.response_time.max: 1000
conditions:
- expression: "vusers.failed == 0"
scenarios:
- name: join a room and chat
engine: socketio
flow:
- emit:
channel: "join"
data: "load-{{ $randomNumber(0, 4) }}"
acknowledge:
match:
json: "$.ok"
value: true
- loop:
- emit:
channel: "message"
data: ["load-1", "hello from artillery"]
acknowledge:
match:
json: "$.ok"
value: true
- think: 2
count: 5
Run it with the official image (or npx artillery run chatterbox-socketio.yml where Node.js is available):
docker run --rm --add-host=host.docker.internal:host-gateway \
-v "$PWD":/scripts:ro \
artilleryio/artillery:latest run /scripts/chatterbox-socketio.yml
The run created 260 virtual users in 30 seconds (an arrival rate rising from 2 to 10 per second, then 10 per second),
all completed with 0 failed, sent 1,560 emits, and reported a socketio.response_time p95 of 0.9 ms and p99 of 2.4 ms. The
ensure plugin printed its verdict, and the exit code is what a pipeline reads:
Checks:
ok: vusers.failed == 0
ok: socketio.response_time.max < 1000
ok: socketio.response_time.p95 < 250
When a threshold is violated (a copy with p95: 0.05 was run to confirm) the line becomes fail: socketio.response_time.p95 < 0.05
and Artillery exits with status 1.
Things to know about Artillery:
-
phaseswitharrivalRateandrampToare an open model: each phase creates virtual users at a rate, and every virtual user runs the scenario once. -
socketio.transports: ["websocket"]removes long-polling from the test. Leave it out to test the default upgrade path, which is what browsers do, and remember that it then needs sticky sessions behind a load balancer. -
A scenario with
thinkkeeps the connection open without sending, which is how to hold 10,000 idle sockets. -
Artillery can drive Playwright too (
engine: playwright, with Web Vitals collected per page); that is the route to browser-level WebRTC tests, with the CPU cost described below. -
The image is published for
linux/amd64; on Apple silicon Docker prints a platform warning and runs it under emulation, which lowers the load a laptop can generate.
Source: Artillery: Playwright engine
JMeter
Apache JMeter has no WebSocket support in the core; the community plugin WebSocket Samplers by Peter Doornbosch
(Plugins Manager id websocket-samplers, 1.3.2) adds six samplers: Open Connection, Close Connection, Single Read,
Single Write, Request-Response and Ping-Pong. It is the maintained option; another plugin of the same kind in the
catalogue only offers a snapshot build. Installing a plugin from the command line, pinning its version in a Docker image
and the Maven route are on
Plugins & the Plugins Manager.
A WebSocket test plan has the shape of a chat session:
-
A Thread Group (or the open-model thread group, see Open Model & Throughput Control) with the user count and ramp-up (see Thread Groups, Concurrent Users & Ramp-Up).
-
An Open Connection sampler to
/ws, with theSec-WebSocket-Protocol: chatterbox.v1header and a connection timeout. -
A Request-Response sampler that writes the
helloJSON and waits for a frame matching a pattern, then another forjoin; JSON extractors keep ids between samplers (see Correlation & Extractors). -
A loop with a timer between
messageframes (Timers & Think Time), then a Close Connection sampler.
Choose JMeter when the organisation already runs JMeter for its HTTP tests and wants one report format, one CI job and one set of dashboards (see HTML Dashboard Report and Real-Time Monitoring with Backend Listener). Choose k6 or Artillery when the scenario is code: custom metrics such as fan-out latency, branching on server messages and thresholds in the script are simpler there. Thread-per-user is also the JMeter limit for connection counts, so holding tens of thousands of sockets needs the distributed mode (Distributed Testing) and generator tuning (Best Practices & Load Generator Tuning).
Choosing between the tools
| k6 | Artillery | JMeter | |
|---|---|---|---|
Scripting |
JavaScript, code in a Go engine |
YAML plus optional JavaScript hooks |
GUI-built |
WebSocket |
|
|
Plugin samplers |
Socket.IO |
Raw frames by hand |
|
Not supported directly |
Custom metrics |
|
Custom metrics through hooks |
JSR223 and listeners |
Open model |
|
|
Open Model Thread Group (experimental) |
CI verdict |
Thresholds, non-zero exit |
|
Assertions plus a report parser |
Cost per connection |
A JavaScript runtime per VU |
A virtual user in one Node.js process per worker |
A thread per user |
WebRTC load testing
A WebRTC test has to decide how real each client must be. The levels, from cheap to costly:
-
Signalling only. Run the WebSocket scenarios above against the signalling server: offers, answers and candidates are JSON messages. This loads the room logic and the backplane but no media code path.
-
Synthetic clients with a media SDK. The server vendor’s own tool, run from the command line: LiveKit ships
lk load-test(publishers and subscribers built with its Go SDK, with 720p looping simulcast video and silent audio at a target bitrate), which measures the real forwarding cost of the SFU without a browser per participant. Plain Pion or aiortc clients (see Pion and aiortc) can do the same for servers without a tool of their own. See LiveKit and mediasoup. -
Headless browsers with fake media. The most realistic client: the real WebRTC stack, real codecs, real congestion control and the application’s own JavaScript. Chrome’s
--use-fake-device-for-media-streamflag supplies a synthetic camera and microphone and--use-fake-ui-for-media-streamaccepts the permission prompt automatically. It is also the most expensive: a participant is a browser process that encodes and decodes video. -
Real devices and browsers across a grid. KITE (Karoshi Interoperability Testing Engine) ran Selenium-grid tests of WebRTC across browsers and mobile devices and offered load testing, but its repository was archived on 2023-12-11, so it is a reference for the approach rather than a tool to adopt. The Jitsi project publishes
jitsi-meet-torture, a Maven test suite pointed at a Jitsi Meet instance, for the same purpose (see Janus and Jitsi).
Browser tests are for validating a room end to end and for the first few dozen participants; they cannot generate the
last 90 percent of a big load, because a laptop runs only a handful of Chrome encoders. Use synthetic clients for volume
and browsers for fidelity, in the same test if the budget allows: for example 200 synthetic subscribers plus 3 real
Chrome pages whose getStats() data tell what a real viewer would see while the SFU is loaded.
A headless-browser harness
The harness opens one browser context per simulated participant, starts a call in each, waits, and reads getStats() from
every page. To keep the example self-contained the page under test is a stand-in that pairs two peer connections in the
same page with the fake camera (so no media server is needed); to load a real application, point PAGE_URL at its room
and replace startCall() and getHuddleStats() with the application’s own entry points.
<!doctype html>
<meta charset="utf-8">
<title>Huddle loopback stand-in</title>
<video id="remote" autoplay muted playsinline></video>
<script>
// Stand-in for a real Huddle room page: one local peer pair publishes the fake camera and receives it.
// A real test points the harness at the application's own URL instead and reads its getStats().
let receiver;
async function startCall() {
const sender = new RTCPeerConnection();
receiver = new RTCPeerConnection();
sender.onicecandidate = (e) => e.candidate && receiver.addIceCandidate(e.candidate);
receiver.onicecandidate = (e) => e.candidate && sender.addIceCandidate(e.candidate);
receiver.ontrack = (e) => { document.getElementById('remote').srcObject = e.streams[0]; };
const media = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 360 }, audio: true });
media.getTracks().forEach((t) => sender.addTrack(t, media));
await sender.setLocalDescription();
await receiver.setRemoteDescription(sender.localDescription);
await receiver.setLocalDescription();
await sender.setRemoteDescription(receiver.localDescription);
}
async function getHuddleStats() {
const out = { framesDecoded: 0, packetsLost: 0, packetsReceived: 0, jitterMs: 0, bytesReceived: 0 };
for (const r of (await receiver.getStats()).values()) {
if (r.type === 'inbound-rtp' && r.kind === 'video') {
out.framesDecoded = r.framesDecoded;
out.packetsLost = r.packetsLost;
out.packetsReceived = r.packetsReceived;
out.jitterMs = Math.round((r.jitter ?? 0) * 1000);
out.bytesReceived = r.bytesReceived;
}
}
return out;
}
</script>
import { chromium } from 'playwright-core';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const CLIENTS = Number(process.env.CLIENTS ?? 4);
const SECONDS = Number(process.env.SECONDS ?? 10);
const PORT = Number(process.env.PORT ?? 18803);
const PAGE = process.env.PAGE_URL ?? `http://localhost:${PORT}/loopback.html`;
// Serve the stand-in page (unused when PAGE_URL points at a real application).
const server = createServer(async (req, res) => {
res.setHeader('content-type', 'text/html');
res.end(await readFile(new URL('./loopback.html', import.meta.url)));
}).listen(PORT);
const browser = await chromium.launch({
executablePath: process.env.CHROME_PATH, // or channel: 'chrome'
headless: true,
args: [
'--use-fake-device-for-media-stream', // synthetic camera and microphone
'--use-fake-ui-for-media-stream', // auto-accept the permission prompt
'--autoplay-policy=no-user-gesture-required',
],
});
const pages = [];
for (let i = 0; i < CLIENTS; i++) {
const context = await browser.newContext(); // one context per simulated participant
const page = await context.newPage();
page.on('pageerror', (e) => console.error(`client ${i}:`, e.message));
await page.goto(PAGE);
await page.evaluate(() => startCall());
pages.push(page);
}
await new Promise((r) => setTimeout(r, SECONDS * 1000));
const rows = [];
for (const [i, page] of pages.entries()) {
rows.push({ client: i, ...(await page.evaluate(() => getHuddleStats())) });
}
console.table(rows);
const dead = rows.filter((r) => r.framesDecoded < SECONDS * 10); // expect roughly 30 fps; accept 10
console.log(dead.length === 0 ? 'all clients decoding' : `${dead.length} client(s) below threshold`);
await browser.close();
server.close();
process.exit(dead.length === 0 ? 0 : 1);
Run it with CHROME_PATH set to a Chrome binary (Playwright’s channel: 'chrome' or its downloaded Chromium work too):
npm install playwright-core@1.64.1
CLIENTS=4 SECONDS=8 node webrtc-load.mjs
Four clients decoded between 160 and 165 video frames each in eight seconds (about 20 frames per second after the
start-up phase), with 0 packets lost, and the script printed all clients decoding and exited 0. The numbers to track
over a ramp are the same as in a real call: framesDecoded per second, packetsLost relative to packetsReceived,
jitter, roundTripTime from the candidate-pair, and the quality-limitation reason of the outbound video (see
Stats, Quality and Bandwidth). Add one client at a time until
the decoded frame rate of the existing clients starts to fall; the number before that is the node’s limit for that
layout. Playwright’s own page, context and fake-device model is on
End-to-End Testing in the Browser.
The cost of a browser participant
Each headless Chrome with an encoder and a decoder uses a large fraction of a core at 640x360, and several times that at 720p, so a generator host saturates within tens of participants. Measure the generator the same way as the server: when the generator’s CPU passes 80 percent, the dropped frames and late stats are produced by the generator and the run is invalid. Lower the resolution and frame rate of the fake device when the goal is to load the server’s signalling or the SFU’s packet path rather than to test encoding, and spread participants over several hosts (or a managed browser grid) for larger runs.
Reading the SFU side
Where the SFU publishes metrics, read them next to the client stats: Prometheus exporters exist for coturn and LiveKit,
mediasoup exposes per-worker resource usage (see
Scaling WebRTC and TURN). The LiveKit benchmark guide frames the
load as three factors: the number of published tracks, the number of subscribers, and the data sent to each subscriber.
It reports, for a 16-core Google Cloud c2-standard-16 node, a large audio room (10 publishers, 3,000 subscribers) at
80 percent CPU, a 150-person video meeting at 85 percent and a 720p livestream to 3,000 subscribers at 92 percent, and
advises simulating one’s own workload instead of relying on those figures.
Measuring connections per node
The question "how many connections does one node hold?" has a different answer for every combination of message rate, fan-out and payload, so measure it in the same way every time:
-
Fix the workload mix. Number of rooms, members per room, messages per connection per second and payload size, taken from production metrics or from the product’s expectations. Chatterbox’s own rate limit (20 envelopes per second per connection) is a ceiling, not a typical rate.
-
Prepare the node. The same container image, CPU and memory limits and kernel settings as production, with TLS and the proxy in front (see Proxies, Load Balancers and Deployment); a test without the proxy leaves out the layer whose idle timeouts and connection limits usually break first.
-
Ramp in steps and hold. Raise the number of connections in steps (for example 1,000 at a time), hold each step long enough for the metrics to settle (a few minutes, not seconds), and sample the server every few seconds.
-
Look for the knee. The limit is the last step before a metric leaves its budget: fan-out p99, event-loop delay, CPU, memory, handshake failures. The first metric to break is the real bottleneck and the one to fix or monitor.
-
Soak. Hold 80 percent of the knee for an hour or more; leaks, timer build-up and slow-consumer queues show up only with time.
-
Disturb it. Restart the node and measure the reconnection storm (jittered backoff versus none; see Connection Lifecycle, Heartbeats and Reconnection), kill a backplane link, and add a slow consumer.
On the laptop used for this page, a run with 500 VUs in 50 rooms (about 10 members per room) held 494 live connections at the plateau; the server process grew from 71 MB RSS before the test to about 88 MB, which is roughly 35 KB per connection including buffers and per-room structures, and its p99 event-loop delay stayed between 12 and 16 ms (the timer resolution is 10 ms, so that is the floor of the measurement). The generator received around 2,000 messages per second against around 230 sent, an average fan-out of about 9 members. Six of the 500 handshakes timed out at 30 seconds in that run while the server logged nothing, which points at the path between the container and the host rather than at the application; a failed handshake count above zero always needs an explanation before the other numbers are trusted.
These figures describe a toy server on one laptop and are not capacity data; the procedure is the point. Below the application, the limits are the ones of Scaling WebSockets: descriptor limits, memory per connection, ephemeral ports on any proxy in front, and the load balancer’s maximum connections and idle timeout (see also Resource Management for the container limits that apply the moment the node runs in a cluster).
From measurements to a capacity plan
A capacity plan is a short calculation with the measurements as inputs.
-
Per-node budget. Take the knee from the test and apply headroom: plan to run at 60 to 70 percent of it, because the fan-out of a busy room, a reconnection storm or a deploy raises the load on the nodes that remain.
-
Demand. From the product: peak concurrent users. If only arrival rate and session length are known, use Little’s law (concurrent = arrival rate x average session duration).
-
Nodes. Divide demand by the per-node budget and round up, then add the redundancy: the cluster must carry the peak with one node down (a failure or a rolling deploy), so compute the per-node load for N minus 1 nodes and check that it is still within budget.
-
Shared components. The backplane (Redis, Kafka or NATS, see Scaling WebSockets) sees every fan-out message once per node, so test the cluster with the real backplane, not only one node. Databases behind the handlers, push gateways and TURN servers each need their own measured limit.
-
Reconnection budget. If one node fails, its connections arrive at the others within the backoff window; check that the handshake rate times the cost of
hello(a ticket check, a history query) fits in what the remaining nodes can absorb. -
Re-test after each major change: a new feature that doubles the average fan-out halves the capacity of a node.
A worked example for Chatterbox, with illustrative numbers:
| Step | Value | Reasoning |
|---|---|---|
Measured knee per node |
8,000 connections |
Fan-out p99 stays under 200 ms up to this step |
Budget at 70 percent |
5,600 connections |
Headroom for bursts |
Demand |
50 new connections/s x 600 s = 30,000 steady, 50,000 at peak |
Little’s law, peak from product forecast |
Nodes for demand |
50,000 / 5,600 = 8.9, so 9 |
Round up |
N minus 1 check |
With 8 nodes left: 6,250 per node, over the 5,600 budget |
9 nodes fail the check |
Plan |
10 nodes |
With 9 left: 5,556 per node, inside the budget |
For media the formula changes shape. An SFU forwards each published track to every subscriber, so the egress of a room is roughly the number of participants times the streams each one receives times the bitrate of a stream: 25 participants each watching 6 streams of 600 kbit/s need about 25 x 6 x 0.6 = 90 Mbit/s of egress per room, which alone makes bandwidth, not CPU, the cost driver of a single large room. Measure it with the synthetic-client tool, with the same layout as the product (how many tiles, which simulcast layers), and read CPU, egress bandwidth and the clients' quality statistics together. TURN relays are sized by relayed bandwidth and by the share of sessions that need them (see ICE, STUN and TURN).
Autoscaling for sockets is slow to react and expensive to be wrong about: scale on connections per node (a custom metric) rather than on CPU alone, scale out ahead of known peaks, and drain nodes on scale-in (see Autoscaling and Scaling WebSockets).
Running load tests responsibly
-
Test your own environment, or one built for the purpose, never someone else’s service and not production without agreement. Real-time servers fail all at once, and a connection test can take down the shared proxy in front of other applications. The JMeter section has the longer version in Security & Responsible Load Testing.
-
Switch off, or whitelist the generator from, the per-IP connection limits, the WAF and the rate limits that the test is not about, and say so in the report; test those limits in a separate run that expects
rate_limitederrors and 4429 closes. -
Use test accounts and generated tickets, not real users, and synthetic content: the messages of a load test land in the history store.
-
Use obviously fake secrets in test configuration (
change-me) and keep real credentials out of scripts and CI variables printed in logs (see Authentication and Authorization). -
For WebRTC, a load test through a public STUN or TURN service is abuse; run your own coturn or LiveKit and keep the test traffic inside the test network.
Edge cases
-
Clock differences. Fan-out latency stamped by the sender and read by the receiver is only valid when both run on the same machine (or on synchronised clocks). Otherwise use the server’s
tsfor one-way delays between server and client and measure the rest as round trips. -
Warm-up. JIT compilation, cold caches and connection-pool growth make the first minute pessimistic; exclude it from the percentiles (k6
startTimeof a second scenario, Artillery warm-up phase). -
Idle connections are not free. A test that only connects and waits misses the heartbeat cost: the Chatterbox heartbeat (every 20 seconds per connection) is 500 frames per second at 10,000 connections.
-
Message size. Load tests with a 20-byte text understate the cost of real payloads, compression (permessage-deflate has a CPU and memory price per connection) and slow-consumer buffering. Replay realistic sizes.
-
Receivers that read slowly. A generator that does not read as fast as the server writes (a saturated k6 process) creates backpressure that the production clients will not; check the generator’s CPU in every report.
-
One room versus many. 10,000 members in one room is a different problem (one hot fan-out) from 1,000 rooms of 10; test the distribution that production has, including the largest room.
Common errors and pitfalls
-
The generator is the bottleneck. Symptoms: latency that rises with the number of VUs while the server is idle, and
dropped_iterationsin k6. Watch the generator’s CPU, memory and network, and add machines. -
"socket: too many open files" or "cannot assign requested address". Descriptor or ephemeral port exhaustion on the generator; raise
ulimit -n, widennet.ipv4.ip_local_port_range, add source addresses. -
A closed model hiding a collapse. Fixed VUs that slow down together look healthy; use an arrival rate for connection tests.
-
Reporting averages. A mean of a few milliseconds hides a p99 of hundreds; always set thresholds on percentiles.
-
Sticky sessions forgotten. Socket.IO with polling enabled fails across a round-robin balancer in the test just as it does in production; either restrict the test to WebSocket (and do so in the application too) or enable stickiness.
-
Everything in one room. A uniform random room choice with few rooms makes the fan-out of every message equal to the total user count; set the room count deliberately.
-
The proxy is not in the path. The test passes at 20,000 connections and production dies at 4,000 because an idle timeout or a connection limit sits in a layer the test skipped.
-
Testing against
localhostover the loopback. No network latency, no packet loss, no MTU effects; add latency and loss withtc netemor a Docker network emulator when the question is about clients on bad networks (see Latency Compensation). -
Reusing a one-time ticket. Authentication tickets are one-time; the generator must request a new one per connection or the second run fails with
unauthorizedcloses. -
Browser-based WebRTC tests with real network congestion. Fake devices produce a fixed pattern that encodes unrealistically well; use real content at least for the bandwidth tests.
Version and tool notes
-
k6 documents
k6/websocketsas the recommended module; thek6/wsmodule andk6/experimental/websocketsremain documented but the experimental one is deprecated. Thews_*metric names are the same in the supported modules. -
Artillery 2.x runs the
socketioandwsengines inside its own runtime (one Node.js process per worker); its Playwright engine and cloud-run commands are separate features. The legacy top-levelensureblock of version 1 is superseded byplugins.ensure. -
The JMeter WebSocket Samplers plugin needs Java 8 or later and JMeter 3.1 or later; the project is community-run.
-
KITE is archived (December 2023); LiveKit’s
lk load-testis documented in its self-hosting guide. -
Chrome’s fake-device flags are Chromium switches, not web standards; Firefox uses a preference (
media.navigator.streams.fake) instead.
Related pages
-
Testing Real-Time Apps: unit, integration and browser tests that come before the load test.
-
Scaling WebSockets and Scaling WebRTC and TURN: what to do with the findings.
-
Observability: the production metrics that mirror the test metrics.
-
Building a Chat App, Building a Videoconference App and Building a Multiplayer Game: the applications to test.
-
Apache JMeter and Docker: Resources, Logging and Monitoring.