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.
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.
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
- 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.
- Inject jitter on the receiving host with
tc qdisc add dev eth0 root netem delay 40ms 25ms distribution normaland 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. - Remove the jitter with
tc qdisc del dev eth0 rootand 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. - 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. - Set
receiver.jitterBufferTarget = 80on the audioRTCRtpReceiverand 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.
Common Implementation Mistakes
- Plotting
jitterBufferDelaywithout dividing. It is cumulative seconds across every sample ever emitted. A dashboard that graphs it directly shows a monotonically rising line that looks alarming and means nothing. - Using round-trip time as the network term. One-way delay is
roundTripTime / 2. Teams that forget the division over-attribute 20β60 ms to the network and then go hunting for routing problems that do not exist. - Forgetting the playout path entirely.
media-playoutis a separate report frominbound-rtp, so a loop that only iterates inbound reports silently drops the single largest term on a Bluetooth listener β the 100β150 ms that made the user complain in the first place. - Averaging over the whole session. Latency complaints are about the worst 5% of a call, not its mean. Keep a rolling window and alert on the p95 of the per-second values, the way freeze and loss thresholds are handled in Alerting on Freeze and Packet-Loss SLOs.
- Lowering
jitterBufferTargetglobally to βfix latency.β It is a per-receiver hint that trades directly against concealment. Applied blindly across a fleet it makes every cellular listener worse to shave 20 ms off desk users who were already fine. - Comparing absolute numbers across browsers. Firefox and Safari populate a narrower set of these fields and their playout accounting differs. Compare a browser against its own baseline, never against another engineβs.
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.