Interpreting getStats() for Congestion Signals
RTCPeerConnection.getStats() returns a flat map of dozens of report objects, and the difference between a working congestion dashboard and a misleading one is knowing which five fields actually carry signal and which report each one lives on. This page is part of the Bandwidth Estimation & Congestion Control guide, and it answers a single operational question: given a live RTCStatsReport, which values tell you the network is congested versus the encoder is overloaded, and at what thresholds do you act.
Context & Trade-offs
The trap that wastes the most time is report confusion. availableOutgoingBitrate lives on the transport report — not inbound-rtp, not outbound-rtp — and reading it from the wrong report yields undefined and a “broken estimator” that works fine. Loss and jitter come from inbound-rtp (the remote’s view, surfaced via remote-inbound-rtp for your outbound media). Round-trip time sits on remote-inbound-rtp (per-stream) or transport (per-pair, as currentRoundTripTime). And qualityLimitationReason lives on outbound-rtp and is the one field that disambiguates the entire problem: it tells you outright whether the encoder is being throttled by bandwidth, cpu, other, or none.
There is also a subtlety in where the loss number comes from. For your own outbound video, the loss the remote actually experienced is reported back to you on remote-inbound-rtp — a delayed mirror, updated each time an RTCP receiver report arrives, so it lags the live link by one report interval. The local inbound-rtp report, by contrast, describes media you are receiving. Mixing the two is a classic error: reading local inbound-rtp loss and attributing it to your uplink. For sender-side congestion decisions, read remote-inbound-rtp; for receiver-side playout decisions, read inbound-rtp. The poll loop below reads remote-inbound-rtp because the question here is whether your outbound stream is congested.
The five signals and their thresholds:
| Field | Report | Healthy | Act when |
|---|---|---|---|
availableOutgoingBitrate |
transport |
tracks target | < 300 kbps sustained > 5 s |
packetsLost (as fraction) |
remote-inbound-rtp / inbound-rtp |
< 2% | > 5% rising; > 12% cut a tier |
jitter (seconds) |
inbound-rtp |
< 30 ms | > 50 ms with low loss → queueing, not congestion |
roundTripTime (seconds) |
remote-inbound-rtp |
< 150 ms | sustained climb signals bufferbloat |
qualityLimitationReason |
outbound-rtp |
none |
bandwidth → network; cpu → encoder |
The cardinal trade-off is rate versus ratio. packetsLost is cumulative and monotonically increasing, so a raw value is meaningless — you must compute the delta between two polls and divide by packets sent or received in that window. The same applies to jitter, which is an instantaneous estimate but only interpretable against a loss baseline: high jitter with under 2% loss is router queueing or asymmetric routing, not capacity exhaustion, and the fix is a deeper jitter buffer rather than a bitrate cut.
Two pairings turn these five raw fields into a diagnosis. The first is qualityLimitationReason against availableOutgoingBitrate: if the reason reads bandwidth and the estimate is low, the network is genuinely the bottleneck and a tier downgrade is correct; if the reason reads cpu while the estimate stays high, the encoder is the bottleneck and cutting bitrate only makes the picture worse without freeing the stalled resource. The second is roundTripTime against packetsLost: a climbing RTT with no loss is bufferbloat — a deep queue inflating latency before it overflows — which the delay-based controller will already be backing off from; loss with a flat RTT is a shallow-buffer path dropping packets outright. Reading these as pairs, not in isolation, is the entire skill. A single field almost never tells you what to do; the relationship between two of them does.
Minimal Runnable Implementation
// Poll the five congestion signals, computing loss as a windowed RATE, not a raw count.
let prev = { lost: 0, recv: 0 };
async function readCongestionSignals(pc) {
const stats = await pc.getStats();
const s = { availBps: null, lossPct: 0, jitterMs: 0, rttMs: 0, limit: 'none' };
for (const r of stats.values()) {
if (r.type === 'transport') {
s.availBps = r.availableOutgoingBitrate ?? null; // ESTIMATE — transport only
if (r.currentRoundTripTime != null) s.rttMs = r.currentRoundTripTime * 1000;
}
if (r.type === 'remote-inbound-rtp' && r.kind === 'video') {
const lost = r.packetsLost ?? 0; // cumulative — needs delta
const recv = (r.packetsReceived ?? 0) + lost;
const dLost = lost - prev.lost, dRecv = recv - prev.recv;
s.lossPct = dRecv > 0 ? (dLost / dRecv) * 100 : 0; // windowed loss rate
prev = { lost, recv };
if (r.jitter != null) s.jitterMs = r.jitter * 1000; // seconds → ms
if (r.roundTripTime != null) s.rttMs = r.roundTripTime * 1000;
}
if (r.type === 'outbound-rtp' && r.kind === 'video') {
s.limit = r.qualityLimitationReason ?? 'none'; // bandwidth | cpu | other | none
}
}
// Decision: separate network congestion from encoder overload before reacting.
if (s.limit === 'cpu') console.warn('encoder-bound — drop a layer, do NOT cut bitrate');
else if (s.lossPct > 12) console.warn('congested — downgrade a tier');
else if (s.jitterMs > 50 && s.lossPct < 2) console.info('queueing/jitter — deepen jitter buffer');
return s;
}
setInterval(() => readCongestionSignals(pc), 1000); // 1 s — finer adds main-thread cost, not signal
Reproduction Steps & Debugging Log Patterns
-
Start a video call and run the poll loop at a 1 s cadence. Baseline output on a clean link:
avail=2480000 loss=0.4 jitter=12ms rtt=42ms limit=none -
Throttle the uplink (
tc netemwith 6% loss, +60 ms jitter). Expected shift:avail=620000 loss=6.1 jitter=58ms rtt=180ms limit=bandwidth // network-boundqualityLimitationReasonflipping tobandwidthis your confirmation the estimator — not the CPU — is throttling the encoder. -
Now pin the CPU instead (encode a 4K source on a 2-core device, clean network). Expected:
avail=2450000 loss=0.3 jitter=14ms rtt=40ms limit=cpu // encoder-boundNote the estimate stays high and loss stays low while
limitreadscpu— the textbook signature of overload masquerading as a quality drop. -
Verify your loss math: a raw cumulative
packetsLostof 4000 on a long call is not “4000% loss.” If your log prints implausible percentages, you are reading the raw count, not the windowed delta from step 1’sprevtracking. -
If
availBpslogsnullevery poll, you are reading it offinbound-rtporoutbound-rtp— move the read to thetransportbranch. -
Cross-check the engine. Chrome surfaces
availableOutgoingBitrateandqualityLimitationReasonreliably; Firefox and Safari expose them inconsistently across versions, so a dashboard that hard-asserts those fields will throw on non-Chrome clients. Log the raw report types your loop actually saw on each browser, and treat a missing field as “unknown,” not “zero” — a??fallback tonullkeeps the decision logic from firing on phantom data.
A note on cadence and cost: getStats() walks the entire stats graph and allocates a fresh report map on every call, so a sub-second poll on a busy connection adds measurable main-thread pressure for no extra signal — the estimator and the RTCP feedback that drives it do not update faster than roughly once a second. If you need a tighter view for a specific diagnosis, scope the call by passing a track selector (pc.getStats(track)) so you walk one stream instead of every transport, codec, and candidate-pair report. And keep the poll on a single timer for the whole connection rather than one per sender; multiple overlapping getStats() calls serialise inside the implementation and skew your windowed deltas.
Common Implementation Mistakes
- Reading
availableOutgoingBitrateoff the wrong report. It is ontransport. The most common cause of a dashboard that shows a permanentlynullestimate. - Treating
packetsLostas a rate. It is cumulative and monotonic; you must diff consecutive polls and divide by packets in the window, or every long call looks catastrophically lossy. - Ignoring
qualityLimitationReason. Without it you cannot tell a congested network from a saturated encoder, and you will cut bitrate on a link that has plenty of headroom. - Reacting to one sample. Network stats are noisy; require a threshold to hold for several consecutive polls (3–5 s) before downgrading, or you will flap.
- Polling faster than 1 s. Sub-second
getStats()adds main-thread overhead without finer signal — the estimator itself does not update that fast.
Frequently Asked Questions
Why is packetsLost sometimes negative between polls?
Out-of-order delivery and late retransmissions can make the cumulative counter appear to step backward across a short window. Clamp the windowed delta to zero rather than reporting negative loss, and average over a 3–5 s window to smooth the artifact.
Is jitter enough to detect congestion on its own?
No. Jitter rises from router queueing and asymmetric routing as readily as from congestion. Interpret it only against packetsLost: high jitter with under 2% loss points to a buffering fix (RTCRtpReceiver.jitterBufferTarget, Chrome 110+), not a bitrate cut.
Why does qualityLimitationReason read bandwidth when my link clearly has headroom?
The field reflects why the encoder was throttled, and GCC’s estimate can lag real capacity by several seconds after a network handoff while it re-probes upward from a conservative floor. During that window the encoder is genuinely bandwidth-limited by the stale estimate even though the path has recovered. Confirm by graphing availableOutgoingBitrate over the next few seconds — if it climbs back, the bandwidth reading was transient re-probing, not a standing limit.
Related: this reference supports Bandwidth Estimation & Congestion Control and feeds adaptive bitrate streaming in WebRTC; when the numbers disagree across engines, cross-check them with cross-browser WebRTC debugging, and pair it with tuning the WebRTC bandwidth estimator for unstable networks.