Protocols10 min read5 октября 2026 г.

Load Testing WebRTC, Step by Step

From a serverless P2P call between virtual users to multi-track simulcast ingest: WHIP publishing, media-quality metrics, ICE restarts, and jitter buffers with the perfscale native engine.

Автор: Perfscale Team

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 perfscale CLI (install instructions); run perfscale self-update to 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 in webrtc_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 background getStats sampler, 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

SymptomLikely cause / fix
Test rejected at creationStarter plan — pro/webrtc-* needs Scale or Enterprise
capabilities validation error in lintPro actions require the plan capability declared; add the capabilities: entry the error names
WHIP connect fails with 401/403Missing or wrong bearer: token for the endpoint
Connect times out, no candidatesNo STUN/TURN reachable — set webrtc.ice_servers in config.yaml (default is Google's public STUN)
webrtc_call_errors_signaling grows with odd VU countsExpected — the unpaired odd VU times out; run an even vus or a custom pairing rule
AV1 publish sags below target fpsReal encoding costs ~1 core per track — lower resolution, add CPUs, or use VP8/H.264 asset sources
resolution ignored — asset-bound 320x240 warningVP8/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 layerwebrtc-rs delivers all layers on one remote track — per-layer receive demux is not available (send-side per-layer metrics are exact)

Next steps