Measuring Audio Latency and Jitter Buffer Delay

β€œThe audio feels laggy” is not a bug report until somebody attaches a number to it. This deep-dive is part of the Audio Processing & Opus Tuning guide, and it answers one question: given a live RTCPeerConnection, how do you compute an actual mouth-to-ear figure in milliseconds, decide whether that figure is healthy, and attribute the milliseconds to the stage that is spending them. The core arithmetic is one division β€” jitterBufferDelay / jitterBufferEmittedCount β€” but that ratio only covers one segment of the path, and reading it in isolation is how teams end up shrinking a buffer that was never the problem.

Context & Trade-offs

Mouth-to-ear is the wall-clock delay between a pressure wave hitting the sending microphone and the corresponding wave leaving the receiving speaker. ITU-T G.114 is the reference most teams anchor to: under 150 ms one-way, conversation feels natural and interruption works; between 150 and 400 ms, turn-taking degrades and participants start talking over each other; above 400 ms, people begin explicitly handing off (β€œover”), which is the point at which users describe a call as broken. WebRTC’s entire architectural advantage over segmented HTTP delivery is that it can land in the first band, so measuring where you actually sit is not an optimisation exercise β€” it is the product.

The budget decomposes into seven stages, and only three of them are visible to getStats(). The capture path costs 10–20 ms of device buffering before a single sample reaches JavaScript. The audio processing module β€” echo cancellation, noise suppression, gain control β€” adds another 10–20 ms, most of it the AEC’s analysis window. Opus adds its frame duration plus algorithmic lookahead: at the default 20 ms ptime that is roughly 26.5 ms before the first packet is emitted. Network one-way delay is approximately half the measured round-trip time, plus 20–40 ms one-way if the path is relayed rather than direct, which is why forcing relay for a connectivity fix has an audible cost worth quantifying against Traversing Symmetric NAT with TURN. The jitter buffer holds 40–80 ms on stable Wi-Fi and 100–200 ms on cellular. Decoding costs 2–5 ms. Finally the playout path β€” the OS mixer and the output device β€” costs 10–40 ms on wired hardware and 100–150 ms over Bluetooth.

Stage Typical one-way cost Visible in getStats() What moves it
Capture buffer 10–20 ms no device, sample rate, OS audio stack
Audio processing module 10–20 ms no echoCancellation, noiseSuppression
Opus frame + lookahead ~26.5 ms at 20 ms ptime no ptime / minptime
Network one-way RTT / 2, +20–40 ms relayed yes (currentRoundTripTime) routing, relay, congestion
Jitter buffer (NetEq) 40–200 ms yes (jitterBufferDelay) arrival jitter, loss, target delay
Decode 2–5 ms folded into the buffer figure codec, CPU contention
Playout / device 10–150 ms yes (totalPlayoutDelay) Bluetooth vs wired, OS mixer

Only one line in that table is meaningfully tunable at runtime, and it is the largest variable one. Everything above the network row is fixed by your capture profile; everything below is fixed by the listener’s hardware. The jitter buffer is the single lever, and it is a genuine trade: NetEq sizes itself so that a chosen percentile of packets arrive before their playout deadline, so every 10 ms you take out of the buffer converts some fraction of late packets into concealment. Under 1% concealed samples is inaudible, above 2% is the warble users call breaking up.

Mouth-to-ear latency budget across three delivery paths Three stacked bars share the same capture, processing and encode cost, but diverge sharply in network one-way delay, jitter buffer depth and playout buffering, totalling 118 ms on wired LAN, 205 ms over Wi-Fi with a TURN relay, and 367 ms on LTE with a Bluetooth headset. Where the milliseconds go: one-way mouth-to-ear budget Identical 20 ms ptime Opus profile, three delivery paths 150 ms β€” natural turn-taking 400 ms Wired LAN 118 ms 22 26 45 Wi-Fi + TURN relay 205 ms 30 26 45 75 29 LTE + Bluetooth 367 ms 35 26 60 150 96 0 100 200 300 400 ms Segment values are in milliseconds, printed under the segment they belong to. capture + APM encode network one-way jitter buffer decode + playout Only the network, jitter-buffer and playout segments are readable from getStats β€” the left half is fixed by your capture profile.
The same encoder settings land at 118 ms or 367 ms depending entirely on the three right-hand segments.

A healthy number, then, is concrete: on a wired or good Wi-Fi path a two-party call should compute to 110–160 ms mouth-to-ear, with a jitter buffer contribution of 40–80 ms and a concealment ratio under 1%. Anything above 250 ms with a near-zero concealment ratio means the buffer is being paid for insurance you are not claiming on. Anything above 250 ms with concealment above 2% means the path itself is bad and the buffer is doing its job.

Minimal Runnable Implementation

The measurement is a delta between two getStats() polls one second apart. Three ratios matter, and all three are cumulative counters that must be differenced β€” plotting them raw produces a line that only ever rises. jitterBufferDelay / jitterBufferEmittedCount gives the mean seconds each emitted sample waited in NetEq. The separate media-playout report gives totalPlayoutDelay / totalSamplesCount, which is the delay from a sample being handed to the playout path until it reaches the device β€” the segment nobody remembers to add. And roundTripTime comes from the remote-inbound-rtp report, derived from RTCP receiver reports, with candidate-pair.currentRoundTripTime as the fallback when RTCP has not yet produced a sample.

// Mouth-to-ear estimator. Poll at 1 s; every figure below is a delta, never a total.
const CAPTURE_MS = 15;   // device buffer, measured once per platform
const APM_MS = 15;       // echo cancellation + noise suppression analysis window
const ENCODE_MS = 26.5;  // 20 ms Opus frame + ~6.5 ms algorithmic lookahead

let prev = null;

async function measureMouthToEar(pc) {
  const stats = await pc.getStats();
  const now = { t: performance.now() };

  for (const r of stats.values()) {
    if (r.type === 'inbound-rtp' && r.kind === 'audio') {
      now.jbDelay = r.jitterBufferDelay;              // cumulative SECONDS
      now.jbEmitted = r.jitterBufferEmittedCount;     // cumulative SAMPLES
      now.jbTarget = r.jitterBufferTargetDelay;       // what NetEq is aiming for
      now.jbMinimum = r.jitterBufferMinimumDelay;     // floor NetEq will not go below
      now.concealed = r.concealedSamples;
      now.received = r.totalSamplesReceived;
    }
    if (r.type === 'media-playout' && r.kind === 'audio') {
      now.playDelay = r.totalPlayoutDelay;            // cumulative seconds
      now.playSamples = r.totalSamplesCount;          // cumulative samples
    }
    if (r.type === 'remote-inbound-rtp' && r.kind === 'audio') {
      now.rtt = r.roundTripTime;                      // seconds, from RTCP RR
    }
    if (r.type === 'candidate-pair' && r.nominated) {
      now.pairRtt = r.currentRoundTripTime;           // fallback before RTCP settles
    }
  }

  if (prev) {
    // Mean buffer delay for samples emitted in THIS window only.
    const emitted = now.jbEmitted - prev.jbEmitted;
    const jbMs = emitted > 0
      ? ((now.jbDelay - prev.jbDelay) / emitted) * 1000
      : 0;
    // Playout path: OS mixer plus the output device itself.
    const played = now.playSamples - prev.playSamples;
    const playMs = played > 0
      ? ((now.playDelay - prev.playDelay) / played) * 1000
      : 0;
    // One-way network delay is HALF the round trip, not the round trip.
    const netMs = ((now.rtt ?? now.pairRtt ?? 0) * 1000) / 2;
    // Concealment over the same window tells you what the buffer bought.
    const samples = now.received - prev.received;
    const conceal = samples > 0
      ? (now.concealed - prev.concealed) / samples
      : 0;

    const total = CAPTURE_MS + APM_MS + ENCODE_MS + netMs + jbMs + playMs;
    console.log(
      `m2e ~${total.toFixed(0)} ms = fixed ${CAPTURE_MS + APM_MS + ENCODE_MS} ` +
      `+ net ${netMs.toFixed(0)} + jb ${jbMs.toFixed(0)} ` +
      `(target ${(now.jbTarget * 1000).toFixed(0)}, floor ${(now.jbMinimum * 1000).toFixed(0)}) ` +
      `+ playout ${playMs.toFixed(0)} | conceal ${(conceal * 100).toFixed(2)}%`
    );
  }
  prev = now;
}

setInterval(() => measureMouthToEar(pc), 1000); // same cadence as congestion polling

The three constants at the top are the honest part of this estimate: they are not measured, they are a per-platform calibration you establish once and then treat as fixed. Everything else is live. Because the counters share a report set with the congestion signals, this loop belongs inside whatever poller you already run for Interpreting getStats() for Congestion Signals rather than on a timer of its own β€” two independent 1 s timers will drift and produce windows that do not line up.

Which getStats field measures which segment of the audio path The eight-stage audio pipeline from microphone ADC to speaker, with five spans drawn beneath it: the capture and encode span has no stats coverage, the network span maps to round trip time over two, the NetEq span maps to jitter buffer delay over emitted count, decode is folded in, and the playout span maps to the media-playout report. Stat provenance: what each counter actually covers Spans align with the stages directly above them Mic ADC 10–20 ms APM 10–20 ms Opus enc 20 + 6.5 ms Network RTT / 2 NetEq 40–200 ms Decoder 2–5 ms Playout buf 10–150 ms Speaker air 1 2 3 4 5 1 no coverage at all β€” calibrate once per platform with a loopback recording 2 roundTripTime / 2 from remote-inbound-rtp, plus 20–40 ms when relayed 3 jitterBufferDelay / jitterBufferEmittedCount, steered by jitterBufferTargetDelay 4 decode is folded into the buffer figure on Chrome; never material on its own 5 media-playout: totalPlayoutDelay / totalSamplesCount β€” the OS mixer and device
Five spans, four counters, one blind region β€” the sender-side stages the receiver's stats can never see.

To close the blind region once, record a loopback: play a click into the sending microphone while capturing both the source and the far-end speaker output into a two-channel recording, then measure the offset between the transients. The difference between that ground truth and your computed figure is your CAPTURE_MS + APM_MS + ENCODE_MS constant, and on a given handset model it stays stable to within a few milliseconds. The same click-offset technique is what drives track alignment in Synchronising Audio and Video in Recordings.

Reproduction Steps & Debugging Log Patterns

  1. Establish a baseline on a wired path between two machines with the estimator running. Let it settle for 30 seconds β€” NetEq’s target moves for the first several seconds of any session.
  2. Inject jitter on the receiving host with tc qdisc add dev eth0 root netem delay 40ms 25ms distribution normal and watch the buffer respond. The target rises within 2–4 seconds; the measured delay follows it over the next 5–10 as the accelerate and preemptive-expand decisions bleed the level toward the new target.
  3. Remove the jitter with tc qdisc del dev eth0 root and time the recovery. Coming down is deliberately slower than going up β€” expect 10–20 seconds before the buffer returns to its floor, because NetEq only shrinks during speech pauses.
  4. Force a relayed path with iceTransportPolicy: 'relay' and confirm the network term moves by the expected 20–40 ms one-way while the buffer term stays flat. If the buffer also jumps, your relay is adding jitter, not just distance.
  5. Set receiver.jitterBufferTarget = 80 on the audio RTCRtpReceiver and re-run step 2. This is the API that lets you ask for a lower target; the concealment ratio is the bill.

A healthy wired session logs a flat line with the buffer sitting near its floor:

// m2e ~121 ms = fixed 56.5 + net 9 + jb 44 (target 48, floor 40) + playout 12 | conceal 0.11%
// m2e ~119 ms = fixed 56.5 + net 8 + jb 43 (target 48, floor 40) + playout 12 | conceal 0.08%
// -- netem applied here --
// m2e ~148 ms = fixed 56.5 + net 9 + jb 70 (target 96, floor 40) + playout 12 | conceal 0.94%
// m2e ~186 ms = fixed 56.5 + net 9 + jb 108 (target 112, floor 40) + playout 12 | conceal 0.21%

Read the third line carefully: the target has already jumped to 96 ms but the measured delay is only 70 ms, and concealment has spiked to nearly 1%. That gap is the buffer still filling β€” NetEq knows it needs more depth and has not yet acquired it, so packets are arriving late in the meantime. By the fourth line the measured delay has caught up to the target and concealment has collapsed back under a quarter of a percent. A run where the measured value never converges on the target, or where the target itself oscillates by more than 30 ms between polls, is the signature of bursty arrival rather than steady jitter.

Diagnosing a high mean jitter buffer delay Starting from a mean jitter buffer delay above 120 milliseconds, compare it against the target delay and then against the concealment ratio to distinguish a genuine network problem from a buffer that is merely being conservative. Reading a high mean jitter-buffer delay Every test below uses one 1-second delta window, never a session total Mean jb delay above 120 ms? delta window, not cumulative The buffer is not your problem audit RTT / 2 and media-playout delay instead no yes Measured delay near target? within 15 ms of the target delay Late bursts or clock drift the buffer never converges on its target no yes Concealment above 2%? concealed / total, same window Genuine loss or jitter raise Opus in-band FEC before shrinking anything yes no NetEq is being conservative try receiver.jitterBufferTarget = 80 Re-measure 10 s after any change β€” NetEq needs several seconds and a speech pause to re-converge.
Two comparisons β€” measured against target, then concealment against 2% β€” separate a bad path from an over-cautious buffer.

Common Implementation Mistakes

FAQ

What mouth-to-ear number should I actually target?

Under 150 ms one-way for conversational audio, which on a good path means a jitter buffer contribution of 40–80 ms and a network term under 20 ms. Treat 150–250 ms as acceptable but worth investigating, and anything sustained above 400 ms as a defect regardless of what users say, because they will have already adapted their speaking style around it.

Why does my measured delay lag behind jitterBufferTargetDelay?

Because the target is a decision and the measured value is a consequence. NetEq raises the target the moment its arrival-time model says it needs depth, then acquires that depth gradually by stretching audio during pauses. A persistent gap between the two means the buffer is being drained as fast as it fills β€” bursty arrival, not steady jitter β€” and no amount of target tuning will close it.

Can I measure latency without touching getStats() at all?

For ground truth, yes, and you should do it once: a two-channel loopback recording of a click gives you the real mouth-to-ear figure including the stages no counter exposes. It does not scale to production, so use it to calibrate the fixed constants in the estimator, then let the live counters track everything that varies. If your capture profile changes β€” particularly if you disable processing for high-fidelity capture β€” recalibrate, because the APM term moves.

Related: return to Audio Processing & Opus Tuning, then read Tuning Opus Bitrate and FEC for Lossy Networks for the concealment side of the trade and Disabling Audio Processing for High-Fidelity Music for what removing the APM does to the fixed half of the budget.