What you'll build
A load test that drives WebRTC through the real media path — every virtual user runs ICE/DTLS-SRTP handshakes and moves actual RTP. We'll start with a P2P call between two virtual users (no server needed at all), then publish synthetic and simulcast media into a WHIP endpoint, and finish with resilience knobs — ICE restart, a receive-side jitter buffer, and recording what the network did to your media.
We'll go in five steps: a serverless P2P call, reading and gating the metrics, WHIP ingest, multi-track + simulcast, and failure injection.
Prerequisites
- The
perfscaleCLI (install instructions); runperfscale self-updateto be current (WebRTC ships in agent 0.4.0) - A workspace on the Scale or Enterprise plan — WebRTC is a paid
capability, and tests using
pro/webrtc-*actions are rejected at creation time for Starter tenants - For steps 3–5, a WHIP endpoint to publish into: your SFU/media server (LiveKit, mediasoup, Galene, a Pion WHIP example). Steps 1–2 need nothing.
Step 1 — A P2P call with no server
The composite pro/webrtc-call@v1 step runs an entire call — pairing,
connect, media both ways, hold, stats, close — between pairs of virtual
users. VUs find each other through ephemeral shared variables, so a
signaling server is not required.
Create test.yaml:
name: webrtc-p2p-smoke
steps:
- name: p2p call
use: pro/webrtc-call@v1
with:
pairing:
key: webrtc-room-1 # memory driver — one engine is enough
strategy: adjacent # vu 2k-1 calls vu 2k
media: bidirectional
hold: 10s
Create config.yaml — an even VU count, so everyone gets a partner:
vus: 2
duration: 30s
Run it:
perfscale run -f test.yaml -c config.yaml
Each pair performs real ICE/DTLS-SRTP handshakes and exchanges synthetic
Opus audio + VP8 video for ten seconds. An odd VU count leaves the highest
odd VU unpaired — it offers, times out, and is counted as a signaling
failure, never silently skipped.
Step 2 — Read the output, gate on it
A WebRTC run prints its own lines next to the shared HTTP ones:
webrtc_setup_ms........: avg=412.30 p(50)=398.10 p(95)=561.00 p(99)=640.20
webrtc_ttff_ms.........: avg=255.80 p(95)=370.10
webrtc_audio_rtt_ms....: avg=18.40 p(95)=42.10
webrtc_video_bitrate_bps: avg=1482.0k
webrtc_calls_total.....: 24
What you're looking at:
webrtc_ice_duration_ms/webrtc_dtls_duration_ms/webrtc_setup_ms— connect-phase latency, one sample per successful connect. A failed connect writes no sample; it counts inwebrtc_connect_errors.webrtc_ttff_ms— time to first frame on the subscribing side.webrtc_{audio,video}_rtt_ms/_jitter_ms/_bitrate_bps/_packets_lost_total— media quality from a backgroundgetStatssampler, one tick per stats interval while connections are open.webrtc_call_errors_<stage>— failure-cause counters naming the stage a call died at:ice,dtls,signaling,publish,subscribe,hold.
Two different failure signals, and you gate on both — the rate for the SLO, the stage counter to pin a regression:
# thresholds in config.yaml
webrtc_setup_ms_failed: ["rate<0.05"]
webrtc_call_errors_ice: ["count==0"]
rate<0.05 means "fewer than 5% of connect attempts failed" (a derived
0/1 sample per invocation); count==0 means "no call died at the ICE
stage, period".
Step 3 — Publish into a WHIP endpoint
WHIP is the standardized ingest endpoint your SFU already speaks. The connect step does signaling (SDP offer/answer over HTTP), the publish step attaches tracks and starts sending:
steps:
- name: publish connect
use: pro/webrtc-connect@v1
with:
signal: whip
url: https://stream.example.com/whip/cam-${vu}
bearer: ${WHIP_TOKEN}
outputs: cam
- name: send media
use: pro/webrtc-publish@v1
with:
id: ${cam.id}
tracks:
- kind: video
codec: av1
source: synthetic
bitrate: 1500kbps
resolution: 1280x720
- kind: audio
codec: opus
source: synthetic
bitrate: 64kbps
- name: measure
use: pro/webrtc-stats@v1
with: { id: ${cam.id} }
- name: hang up
use: pro/webrtc-close@v1
with: { id: ${cam.id} }
cam-${vu} expands per virtual user — every VU ingests its own stream.
A word on codecs, because the engine is pure Rust and honesty matters:
AV1 is really encoded by rav1e at your requested resolution and bitrate
— budget roughly one CPU core per encoded track. VP8 and H.264 replay an
embedded 320x240 test-pattern asset; a resolution: that doesn't match is
validated and logged as a warning, not silently faked. source: file
loops your own media instead: IVF (VP8/AV1), Annex-B H.264, Opus-in-Ogg.
Custom signaling (LiveKit rooms, mediasoup, Janus) doesn't need engine
support: signal: library hands the SDP offer to a wasm library you
version yourself, and it returns the answer. See the
WebRTC reference for the calling convention.
Step 4 — Multi-track and simulcast
Real clients send more than one camera. WHIP has no renegotiation, so the send layout is declared at connect time, then claimed track-by-track at publish:
- name: studio connect
use: pro/webrtc-connect@v1
with:
signal: whip
url: https://stream.example.com/whip/studio-${vu}
bearer: ${WHIP_TOKEN}
tracks: # default is 1 audio + 1 video
- { kind: audio }
- { kind: video } # camera
- kind: video # screen share, simulcast
layers: [ { rid: f }, { rid: h }, { rid: q } ]
outputs: studio
- name: publish all
use: pro/webrtc-publish@v1
with:
id: ${studio.id}
tracks:
- { kind: audio, codec: opus, source: synthetic }
- { kind: video, codec: av1, source: synthetic, bitrate: 1500kbps }
- kind: video
codec: av1
source: synthetic
layers:
- { rid: f, bitrate: 1500kbps, resolution: 1280x720 }
- { rid: h, bitrate: 600kbps, resolution: 640x360 }
- { rid: q, bitrate: 250kbps, resolution: 320x180 }
Simulcast layers produce a=rid / a=simulcast lines in the offer and
per-layer SSRCs on the wire — the same shape a browser's sendEncodings
makes. Send-side metrics split per track and per layer:
webrtc_video0_bitrate_bps, webrtc_video1_bitrate_bps,
webrtc_video1_f_bitrate_bps, …
One honest limitation: true AV1 SVC (spatial layers inside one stream) is
rejected with a targeted error — rav1e, the only pure-Rust encoder in the
stack, has no spatial-layer API. Use simulcast layers: instead.
Step 5 — Survive the network
Three knobs turn a happy-path test into a resilience test.
Real ICE restart. By default an ICE disconnect fails later steps
(on_disconnect: fail_fast — the honest default for a load test). Switch
to restart and the connection renegotiates: fresh ICE credentials,
re-signaled over WHIP PATCH (application/trickle-ice-sdpfrag) or through
your signaling library, up to 3 attempts:
- use: pro/webrtc-connect@v1
with:
signal: whip
url: https://stream.example.com/whip/cam-${vu}
bearer: ${WHIP_TOKEN}
on_disconnect: restart
Successful restarts count into webrtc_ice_restarts_total — kill a TURN
server mid-run and watch the counter absorb it.
Receive-side jitter buffer. jitter_buffer_ms on the subscribe step
holds packets to an RTP-timestamp playout schedule — absorbing jitter costs
TTFF of the same size, exactly like production:
- use: pro/webrtc-subscribe@v1
with:
id: ${cam.id}
sink: measure
jitter_buffer_ms: 80
Record what arrived. sink: record persists every received track per
VU (.ogg / .ivf / .h264), so a bad run leaves evidence you can play
back:
sink: record
record:
dir: ./artifacts
Gate it in CI
Export the parsed summary and put it in the job output:
perfscale run -f test.yaml -c config.yaml \
--summary-export results.json \
--summary-export "$GITHUB_STEP_SUMMARY" --summary-format md
results.json carries the summary metrics — one jq expression away from
a hard gate. The full CI wiring is covered in
Continuous load testing with GitHub Actions.
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| Test rejected at creation | Starter plan — pro/webrtc-* needs Scale or Enterprise |
capabilities validation error in lint | Pro actions require the plan capability declared; add the capabilities: entry the error names |
| WHIP connect fails with 401/403 | Missing or wrong bearer: token for the endpoint |
| Connect times out, no candidates | No STUN/TURN reachable — set webrtc.ice_servers in config.yaml (default is Google's public STUN) |
webrtc_call_errors_signaling grows with odd VU counts | Expected — the unpaired odd VU times out; run an even vus or a custom pairing rule |
| AV1 publish sags below target fps | Real encoding costs ~1 core per track — lower resolution, add CPUs, or use VP8/H.264 asset sources |
resolution ignored — asset-bound 320x240 warning | VP8/H.264 synthetic tracks replay the fixed asset; only AV1 encodes at the requested size, or use source: file |
| Subscribe-side can't pick a simulcast layer | webrtc-rs delivers all layers on one remote track — per-layer receive demux is not available (send-side per-layer metrics are exact) |
Next steps
- WebRTC reference — every parameter of the
pro/webrtc-*actions, the codec/container truth table, the failure model - WebRTC from the CLI — the shorter getting-started version
- Why we built WebRTC support — the background story
- Shared variables — the rendezvous
behind
pro/webrtc-call@v1, including the Redis driver for multi-agent tests