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.

Which report owns which congestion fieldThe getStats result split into transport, remote-inbound-rtp, inbound-rtp and outbound-rtp reports, showing which fields live on each and which question each answers.const stats = await pc.getStats();one flat map — four report types carry the signaltransportavailableOutgoingBitratecurrentRoundTripTimebytesSentthe only report thatcarries the estimateremote-inbound-rtppacketsLostjitterroundTripTimethe remote’s view ofyour uplink (RTCP lag)inbound-rtppacketsLostjitterframesDecodedmedia you arereceiving, not sendingoutbound-rtpqualityLimitationReasontargetBitratebandwidth | cpu |other | nonesender-side: is my uplink congested?receiver-side playoutencoder headroomReading availableOutgoingBitrate off inbound-rtp or outbound-rtp yields undefined — the classic “broken estimator” report.
Each congestion field lives on exactly one report type; reading it from the wrong one returns undefined.

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. How much it lags depends on which feedback mechanism the session negotiated, which is why the choice of transport-CC vs REMB feedback changes the resolution of everything you are about to graph. 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 — the technique covered in measuring audio latency and jitter buffer delay applies to video playout for the same reason.

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

The prev tracking above is the whole trick, and it is easier to see on a timeline than in code: each poll captures a cumulative pair, and the loss rate only exists in the difference between two adjacent captures.

Cumulative counters to windowed loss rateFour one-second polls capture cumulative packetsLost and packetsReceived; the difference between adjacent polls yields the loss percentage for that window.packetsLost is cumulative — the loss rate lives in the delta between pollspacketsLost120packetsReceived6000t = 0 spacketsLost128packetsReceived6480t = 1 spacketsLost168packetsReceived6940t = 2 spacketsLost173packetsReceived7420t = 3 sΔlost 8Δrecv 480loss = 1.7 %healthy — no actionΔlost 40Δrecv 460loss = 8.7 %above 5 % and risingΔlost 5Δrecv 480loss = 1.0 %recoveredA cumulative packetsLost of 173 is not 173 % loss — clamp negative deltas to zero and smooth over a 3–5 s window.
Three one-second windows turn two monotonic counters into an actionable loss percentage.

Reproduction Steps & Debugging Log Patterns

  1. 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
    
  2. Throttle the uplink (tc netem with 6% loss, +60 ms jitter). Expected shift:

    avail=620000 loss=6.1 jitter=58ms rtt=180ms limit=bandwidth  // network-bound
    

    qualityLimitationReason flipping to bandwidth is your confirmation the estimator — not the CPU — is throttling the encoder.

  3. 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-bound
    

    Note the estimate stays high and loss stays low while limit reads cpu — the textbook signature of overload masquerading as a quality drop.

  4. Verify your loss math: a raw cumulative packetsLost of 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’s prev tracking.

  5. If availBps logs null every poll, you are reading it off inbound-rtp or outbound-rtp — move the read to the transport branch.

  6. Cross-check the engine. Chrome surfaces availableOutgoingBitrate and qualityLimitationReason reliably; Firefox and Safari expose them inconsistently across versions, so a dashboard that hard-asserts those fields will throw on non-Chrome clients — the WebKit-specific gaps and the tooling that exposes them are covered in debugging WebRTC on Safari and iOS WKWebView. Log the raw report types your loop actually saw on each browser, and treat a missing field as “unknown,” not “zero” — a ?? fallback to null keeps the decision logic from firing on phantom data.

Those three signatures — network-bound, encoder-bound, and queueing — collapse into a single ordered test once you stop reading fields in isolation. Evaluate qualityLimitationReason first, because it is the only field that can veto a bitrate cut outright; only then does the loss and jitter pair decide which network remedy applies.

From signature to remedyAn ordered decision tree: check qualityLimitationReason for cpu first, then windowed loss above twelve percent, then jitter above fifty milliseconds with low loss, otherwise treat climbing RTT with flat loss as bufferbloat.threshold held for 3–5 consecutive polls (never react to a single sample)qualityLimitationReasonreads “cpu”?yesencoder-bounddrop a layer or resolution, not bitratenopacketsLostwindowed rate above 12 %?yesnetwork-bounddowngrade one quality tiernojitterabove 50 ms with loss under 2 %?yesqueueing, not congestiondeepen the jitter buffernoroundTripTimeclimbing while loss stays flatbufferbloathold — the estimator is already backing off
Order matters: qualityLimitationReason is evaluated first because it can veto a bitrate cut entirely.

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

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. Shaping how quickly your application follows that recovery is a separate problem, handled in ramping bitrate back up after congestion.

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.