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 ws_connecting; Artillery session timings

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, maxConnections, rate limit

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

ws_msgs_sent and ws_msgs_received; server counters

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

monitorEventLoopDelay, JVM GC logs

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

getStats(): jitter, packet loss, frames decoded, round-trip time

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/websockets is 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 and setInterval and setTimeout are the global functions.

  • k6/ws is the older callback API (ws.connect(url, params, callback)), still supported.

  • k6/experimental/websockets is 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.

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:

  • phases with arrivalRate and rampTo are 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 think keeps 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.

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:

  1. 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).

  2. An Open Connection sampler to /ws, with the Sec-WebSocket-Protocol: chatterbox.v1 header and a connection timeout.

  3. A Request-Response sampler that writes the hello JSON and waits for a frame matching a pattern, then another for join; JSON extractors keep ids between samplers (see Correlation & Extractors).

  4. A loop with a timer between message frames (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 .jmx, Groovy for logic

WebSocket

k6/websockets, browser-like API

ws engine (send only)

Plugin samplers

Socket.IO

Raw frames by hand

socketio engine with the real client

Not supported directly

Custom metrics

Trend, Counter, Rate, Gauge

Custom metrics through hooks

JSR223 and listeners

Open model

ramping-arrival-rate

arrivalRate phases

Open Model Thread Group (experimental)

CI verdict

Thresholds, non-zero exit

ensure plugin, 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:

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

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

  3. 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-stream flag supplies a synthetic camera and microphone and --use-fake-ui-for-media-stream accepts the permission prompt automatically. It is also the most expensive: a participant is a browser process that encodes and decodes video.

  4. 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:

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

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

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

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

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

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

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

  2. 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).

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

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

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

  6. 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_limited errors 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 ts for 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 startTime of 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_iterations in 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, widen net.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 localhost over the loopback. No network latency, no packet loss, no MTU effects; add latency and loss with tc netem or 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 unauthorized closes.

  • 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/websockets as the recommended module; the k6/ws module and k6/experimental/websockets remain documented but the experimental one is deprecated. The ws_* metric names are the same in the supported modules.

  • Artillery 2.x runs the socketio and ws engines inside its own runtime (one Node.js process per worker); its Playwright engine and cloud-run commands are separate features. The legacy top-level ensure block of version 1 is superseded by plugins.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-test is 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.