Stats, Quality and Bandwidth

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Every RTCPeerConnection keeps a running set of measurements about what it sent, what it received and how the network behaved. getStats() returns a snapshot of them as an RTCStatsReport. Those numbers are the only objective way to answer "why does the call look bad?", to draw a quality indicator in the user interface, to decide when to lower the video bitrate, and to find out afterwards which calls went wrong. This page explains the report, the handful of fields that matter, how congestion control produces the bandwidth estimate you can read, and then builds three pieces for Huddle: a quality monitor, a bitrate adapter and a stats reporter.

Baseline, checked on 2026-10-24: the W3C WebRTC 1.0 Recommendation (2025-03-13 edition) defines getStats(); the statistics identifiers live in the separate W3C Identifiers for WebRTC’s Statistics API, a Candidate Recommendation Draft of 2025-09-25; congestion feedback is specified by RFC 8888, while Google Congestion Control (GCC) and transport-wide congestion control (TWCC) remain expired Internet-Drafts. The examples were run in Chrome 155 (headless, with fake media devices) on a loopback call.

Which scenarios use it

  • Huddle (videoconference) is the main user: a quality badge per participant, automatic downgrading of the video bitrate, a diagnostics panel for support and a per-call summary sent to the backend. See Videoconference Architecture.

  • Pixel Arena (game) uses the same candidate-pair numbers to show ping for a peer-to-peer data channel and the data channel counters in peer-connection and data-channel reports; see Data Channels.

  • Chatterbox (chat) does not use WebRTC statistics. Its connection health comes from WebSocket heartbeats, see Connection Lifecycle, Heartbeats and Reconnection.

The statistics API

pc.getStats() returns a promise for an RTCStatsReport, a read-only map from a stat id to a plain dictionary. Each dictionary has id, type and timestamp (a high-resolution time in milliseconds) plus fields specific to the type. Everything is a snapshot: counters such as bytesReceived only ever grow, so rates are always computed from two samples using the difference of the values divided by the difference of the timestamps. Reading a single sample tells you the average since the call began, which hides a problem that started ten seconds ago.

const report = await pc.getStats();
for (const stat of report.values()) {
  console.log(stat.type, stat.id);
}

Variants worth knowing:

  • pc.getStats(track) restricts the report to the objects related to one track, and sender.getStats() or receiver.getStats() do the same for one RTCRtpSender or RTCRtpReceiver. They are cheaper than a full report in a mesh call with several connections.

  • The report is generated on demand and its cost grows with the number of streams, so poll every one to five seconds, never per animation frame, and never run several overlapping polls on the same connection.

  • Fields can be missing. A field is present only when the browser measures it and the stream is in a state where it makes sense. Always test for undefined before doing arithmetic, as the examples below do.

Report types

The types you will meet in a one-to-one call, as observed in Chrome 155, and what each one is for:

Type What it describes

outbound-rtp

One sent RTP stream (one per encoding when simulcast is used): bytesSent, packetsSent, targetBitrate, framesEncoded, frameWidth, frameHeight, framesPerSecond, qualityLimitationReason, qualityLimitationDurations, nackCount, pliCount, active, scalabilityMode.

inbound-rtp

One received RTP stream: bytesReceived, packetsReceived, packetsLost, jitter, framesDecoded, framesDropped, freezeCount, totalFreezesDuration, jitterBufferDelay, jitterBufferEmittedCount, nackCount, and for audio concealedSamples and audioLevel.

remote-inbound-rtp

What the other side says it received from your outbound-rtp, taken from its RTCP receiver reports: packetsLost, fractionLost, jitter, roundTripTime. This is how a sender learns about loss on its own uplink.

remote-outbound-rtp

What the other side says it sent (from RTCP sender reports): used to compute round-trip time on the receive side.

media-source

The local capture source feeding a sender: width, height, framesPerSecond for video, audioLevel for audio. A low value here means the camera or screen capture is the bottleneck, not the network.

candidate-pair

One ICE candidate pair: state, nominated, currentRoundTripTime, totalRoundTripTime, availableOutgoingBitrate, bytesSent, bytesReceived, requestsSent, responsesReceived.

transport

The DTLS/ICE transport: selectedCandidatePairId, dtlsState, iceState, dtlsCipher, srtpCipher, selectedCandidatePairChanges.

local-candidate, remote-candidate

Candidate details: candidateType (host, srflx, prflx, relay), protocol, address, port. The address field is personal data, see Privacy of stats.

codec

mimeType, payloadType, clockRate, sdpFmtpLine: join with codecId from an RTP stream to learn which codec is really in use.

peer-connection, data-channel

Counts of opened and closed data channels, and per channel messages and bytes sent and received.

media-playout, certificate

Audio playout counters and the DTLS certificate fingerprints.

Reports reference each other by id: an outbound-rtp has transportId, codecId, mediaSourceId and remoteId; a transport has selectedCandidatePairId. To find the active network path, follow transport.selectedCandidatePairId rather than looking for the last pair marked nominated, which can name an older pair after an ICE restart.

The fields that matter

Dozens of fields exist, but a quality decision needs only a few. All time values are in seconds, not milliseconds.

Field Where How to read it

currentRoundTripTime

candidate-pair

Round-trip time of the ICE connectivity checks, in seconds. Under 0.15 is comfortable for conversation; over 0.4 makes turn-taking awkward.

roundTripTime

remote-inbound-rtp

Round-trip time measured by RTCP. It tracks the media path more closely than the ICE checks.

jitter

inbound-rtp

Variation of packet arrival time in seconds, as defined by RFC 3550. High jitter forces the receiver to grow its jitter buffer, which adds latency.

packetsLost, packetsReceived

inbound-rtp

Cumulative counts. The loss percentage over an interval is the difference in lost divided by the difference in lost plus received. packetsLost can go negative when duplicate packets arrive, so clamp it at zero.

framesDropped, freezeCount

inbound-rtp (video)

Frames the receiver decoded or received but did not show, and the number of visible freezes. These describe what the viewer actually saw.

framesPerSecond, frameWidth, frameHeight

inbound-rtp, outbound-rtp

Delivered frame rate and size. A sender whose frameWidth is half of the capture width is being scaled down.

targetBitrate

outbound-rtp

The encoder’s current target in bits per second, after congestion control and your maxBitrate.

availableOutgoingBitrate

candidate-pair

The congestion controller’s estimate of the uplink capacity, in bits per second. It is not exposed by every browser or in every state, so treat it as optional.

qualityLimitationReason

outbound-rtp (video)

Why the encoder is producing less than the source offers: none, bandwidth, cpu or other. Together with qualityLimitationDurations (seconds spent in each state) it separates a congested network from an overloaded machine.

jitterBufferDelay, jitterBufferEmittedCount

inbound-rtp

Divide the first by the second for the average time frames or samples waited in the jitter buffer.

A call-quality indicator for Huddle

The monitor below samples a connection once per interval, turns counter differences into rates, picks the active candidate pair through the transport, and condenses everything into one of good, fair, poor or unknown. The thresholds are starting points to tune against your own call data, not standards.

// quality.js: Huddle call-quality monitor
export class QualityMonitor {
  #pc; #prev = new Map(); #timer = null;

  constructor(pc, onSample, intervalMs = 2000) {
    this.#pc = pc;
    this.onSample = onSample;
    this.intervalMs = intervalMs;
  }

  start() { this.#timer = setInterval(() => this.sample().catch(console.error), this.intervalMs); }
  stop() { clearInterval(this.#timer); }

  async sample() {
    const report = await this.#pc.getStats();
    let transport = null, pair = null;
    for (const s of report.values()) if (s.type === 'transport') transport = s;
    for (const s of report.values()) {
      if (s.type === 'candidate-pair' && s.state === 'succeeded' &&
          (s.nominated || s.id === transport?.selectedCandidatePairId)) pair = s;
    }
    const out = {
      rttMs: pair?.currentRoundTripTime != null ? pair.currentRoundTripTime * 1000 : null,
      availableOutKbps: pair?.availableOutgoingBitrate != null ? pair.availableOutgoingBitrate / 1000 : null,
      video: null, audio: null, limitation: null,
    };
    for (const s of report.values()) {
      const prev = this.#prev.get(s.id);
      this.#prev.set(s.id, s);
      if (!prev) continue;                       // first sample only primes the counters
      const dt = (s.timestamp - prev.timestamp) / 1000;
      if (dt <= 0) continue;
      if (s.type === 'inbound-rtp') {
        const lost = Math.max(s.packetsLost - prev.packetsLost, 0);
        const received = s.packetsReceived - prev.packetsReceived;
        out[s.kind] = {
          kbps: ((s.bytesReceived - prev.bytesReceived) * 8) / dt / 1000,
          lossPct: lost + received > 0 ? (100 * lost) / (lost + received) : 0,
          jitterMs: (s.jitter ?? 0) * 1000,
          fps: s.framesPerSecond ?? null,
          framesDropped: s.framesDropped != null ? s.framesDropped - prev.framesDropped : null,
        };
      } else if (s.type === 'outbound-rtp' && s.kind === 'video') {
        out.limitation = s.qualityLimitationReason ?? null;
      }
    }
    out.score = QualityMonitor.score(out);
    this.onSample(out);
    return out;
  }

  static score(o) {
    if (o.rttMs == null && !o.video && !o.audio) return 'unknown';
    const loss = Math.max(o.video?.lossPct ?? 0, o.audio?.lossPct ?? 0);
    const jitter = Math.max(o.video?.jitterMs ?? 0, o.audio?.jitterMs ?? 0);
    const rtt = o.rttMs ?? 0;
    if (loss > 8 || rtt > 400 || jitter > 80 || (o.video?.fps != null && o.video.fps < 8)) return 'poor';
    if (loss > 2 || rtt > 200 || jitter > 30 || o.limitation === 'bandwidth') return 'fair';
    return 'good';
  }
}

On a loopback call between two peer connections in the same page, with Chrome’s fake camera at 640 by 360, a second sample on the receiving side reported about 210 kbit/s of video, 25 kbit/s of audio, no loss, no jitter and a score of good. Loopback never shows loss, so exercise the fair and poor branches with an impairment tool (macOS Network Link Conditioner, Linux tc netem; DevTools network throttling does not affect WebRTC media) before trusting the numbers.

Using it in a Huddle tile takes a few lines. In mesh mode each remote participant has its own peer connection and its own monitor; in SFU mode there is one connection to the server, so the badge describes the participant’s link to the SFU, not to each other participant.

<span id="badge" class="badge" data-score="unknown" role="status" aria-live="polite">Checking connection</span>
<script type="module">
  import { QualityMonitor } from './quality.js';

  const labels = { good: 'Good connection', fair: 'Unstable connection', poor: 'Poor connection', unknown: 'Checking connection' };
  const badge = document.getElementById('badge');

  export function attachBadge(pc) {
    const monitor = new QualityMonitor(pc, (s) => {
      badge.dataset.score = s.score;
      badge.textContent = labels[s.score];
    });
    monitor.start();
    pc.addEventListener('connectionstatechange', () => {
      if (['closed', 'failed'].includes(pc.connectionState)) monitor.stop();
    });
    return monitor;
  }
</script>

Use text and a status role, not colour alone, so the state reaches screen-reader users and people with colour vision deficiency, and keep aria-live polite so a flapping connection does not interrupt speech.

Bandwidth estimation and congestion control

A WebRTC sender does not know how fast the path is. It discovers it continuously with a congestion controller that watches how packets are delayed and lost, and changes the encoder target to match. The number you can read as availableOutgoingBitrate is that controller’s estimate.

  • Google Congestion Control (GCC) is the algorithm in libwebrtc, and therefore in Chrome, Edge, Safari and most native stacks. It combines a delay-based estimator, which reacts when packets start queuing in a router before any is lost, with a loss-based estimator, and takes the lower of the two. It is described in an Internet-Draft (draft-ietf-rmcat-gcc) that expired in 2017.

  • Transport-wide congestion control (TWCC) is the feedback mechanism GCC uses: the receiver reports the arrival time of every packet of a connection, across all media streams, in RTCP feedback messages, so the sender can compute delay variation across the whole transport. It is specified in an Internet-Draft (draft-holmer-rmcat-transport-wide-cc-extensions) that expired in 2016, yet it is implemented by browsers, mediasoup, LiveKit, Janus and Pion, so SDP offers negotiate it through the transport-cc feedback type.

  • RFC 8888 is the published Standards Track alternative for congestion feedback: it defines an RTCP report that carries per-packet arrival information. Cite it when you need a stable reference, and treat TWCC and GCC as widely implemented drafts, not standards.

  • Other algorithms from the IETF RMCAT effort (NADA, SCReAM) exist for specific deployments, but browsers ship GCC.

Consequences you can use:

  • The estimate starts low and ramps up over several seconds after a call begins or after a path change, so do not judge a call by its first three samples. The quality monitor above ignores the first sample for the same reason.

  • With an SFU the downlink estimate lives in the server. The server decides which simulcast layer to forward to each receiver; see Codecs, Simulcast and SVC and mediasoup.

  • In mesh mode a participant uploads one copy per peer, so the controller sees the sum. Four people at 1.2 Mbit/s need 3.6 Mbit/s of uplink; see Topologies: Mesh, SFU and MCU.

  • A TURN relay adds a hop and a bandwidth cost on your server; see ICE, STUN and TURN and Scaling WebRTC and TURN.

Adapting quality

The browser already adapts on its own: degradationPreference decides whether it lowers resolution, frame rate or neither when it must cut the bitrate, and qualityLimitationReason tells you it happened. Your application adds policy the browser cannot know, such as "a screen share must keep its resolution", "capped at 500 kbit/s on a metered connection" or "stop sending video entirely when the score is poor and keep audio".

RTCRtpSender.setParameters() changes the encoding parameters without renegotiation. Always start from the object returned by getParameters(), edit it and hand it back; the browser rejects parameters that were not obtained from it. The adapter below moves the video sender between three tiers: it degrades immediately when the score drops, and recovers one tier at a time only after several consecutive good samples, which prevents oscillation.

// adapt.js: bitrate tiers for the Huddle video sender
const TIERS = {
  good: { maxBitrate: 1_200_000, scaleResolutionDownBy: 1, maxFramerate: 30 },
  fair: { maxBitrate: 500_000, scaleResolutionDownBy: 2, maxFramerate: 24 },
  poor: { maxBitrate: 150_000, scaleResolutionDownBy: 4, maxFramerate: 15 },
};

export function createAdapter(sender, { upAfter = 3 } = {}) {
  let current = 'good', goodStreak = 0;
  return async function onScore(score) {
    if (score === 'unknown') return current;
    let next = current;
    if (score === 'poor' || (score === 'fair' && current === 'good')) next = score;
    else if (score === 'good' && current !== 'good' && ++goodStreak >= upAfter) {
      next = current === 'poor' ? 'fair' : 'good';
    }
    if (score !== 'good') goodStreak = 0;
    if (next === current) return current;
    goodStreak = 0;
    const params = sender.getParameters();
    if (!params.encodings?.length) return current;
    Object.assign(params.encodings[0], TIERS[next]);
    await sender.setParameters(params);
    current = next;
    return current;
  };
}

Driven with the score sequence good, fair, poor, good, good, good, good and upAfter: 2, the adapter produced tiers good, fair, poor, poor, fair, fair, good, and the sender reported maxBitrate values of 500000 and 150000 on the way down. With the cap at 150 kbit/s the encoder’s targetBitrate read 150000 and the measured video rate fell from about 210 to about 150 kbit/s.

Wire it to the monitor by passing each score to the adapter:

import { QualityMonitor } from './quality.js';
import { createAdapter } from './adapt.js';

const videoSender = pc.getSenders().find((s) => s.track?.kind === 'video');
const adapt = createAdapter(videoSender);
new QualityMonitor(pc, (sample) => { adapt(sample.score).catch(console.error); }).start();

Other levers:

  • degradationPreference (maintain-framerate, maintain-resolution, balanced) is a sender parameter that steers the browser’s own adaptation. Use maintain-resolution for screen sharing of text and maintain-framerate for people.

  • track.contentHint (motion, detail, text) tells the encoder what the content is.

  • active: false on an encoding stops sending it without renegotiation, which is the right way to pause video on a poor link while audio continues.

  • In an SFU call, prefer simulcast layers over maxBitrate: the server switches layers per receiver without involving the sender. See Codecs, Simulcast and SVC.

  • Audio is cheap: Opus runs well from about 16 to 64 kbit/s and tolerates loss with in-band forward error correction. Protect audio first when the link is poor; people tolerate frozen video much better than choppy speech.

Uploading stats for analysis

A live badge helps the participant; a record of the call helps you. Upload a compact summary, not the raw reports: raw reports run to tens of kilobytes per sample and contain IP addresses. navigator.sendBeacon() is built for this because the browser sends the request even while the page unloads.

// report.js: collect samples and upload one summary per call
export function createReporter(url, callId) {
  const samples = [];
  return {
    add(s) {
      samples.push({
        t: Date.now(), score: s.score, rttMs: s.rttMs,
        lossPct: s.video?.lossPct ?? null, jitterMs: s.video?.jitterMs ?? null,
        kbps: s.video?.kbps ?? null, limitation: s.limitation,
      });
    },
    flush() {
      if (!samples.length) return false;
      const body = JSON.stringify({ callId, samples: samples.splice(0) });
      return navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
    },
  };
}

// usage: flush every minute and when the page is hidden
// const reporter = createReporter('/stats', roomCallId);
// setInterval(() => reporter.flush(), 60_000);
// addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') reporter.flush(); });

A test with a small Node.js receiver accepted the beacon with a 204 response and printed the JSON body with its application/json content type. Notes on the design:

  • Flush on visibilitychange to hidden, not on unload: unload handlers are unreliable and block the back/forward cache. The same reasoning applies to WebSocket cleanup, see The Browser WebSocket API.

  • A beacon body is limited by the browser (commonly 64 KiB of queued data), so flush regularly instead of once at the end.

  • Authenticate the endpoint with the same short-lived call token as the signalling channel, rate-limit it and validate the shape; it is an unauthenticated write path otherwise. See Authentication and Authorization.

  • Aggregate server-side into per-call percentiles and feed dashboards and alerts; see Observability. On the server, mediasoup, LiveKit and Janus expose their own transport and producer statistics, which you compare with the client view to tell a client-side problem from a server-side one.

Privacy of stats

Statistics are rich enough to identify a person or device. Candidate reports include local and remote IP addresses (with host candidates obscured by mDNS names until permission is given), and RTP reports expose encoder and decoder implementation names and hardware acceleration flags. The networkType field was deprecated in the statistics specification for privacy reasons. Upload only the derived numbers, keep the candidate address and ip fields on the client, and state in your privacy notice that call-quality metrics are collected.

Common errors and pitfalls

  • Reading rates from one sample. Counters are cumulative. Compute differences between two reports and divide by the timestamp difference, not by your timer interval.

  • Mixing units. Times are in seconds and bitrates in bits per second; bytesReceived is bytes. A jitter of 0.03 is 30 ms.

  • Assuming a field exists. Absent fields are normal: availableOutgoingBitrate before the first estimate, video fields on an audio-only call, framesDropped on audio. Guard every access.

  • Indexing reports by position or by id across reconnects. Ids can change when a transceiver is replaced or after an ICE restart. Key your previous-sample map by id but reset it when the connection restarts.

  • Judging the sender by its own reports. Loss on the uplink shows up in remote-inbound-rtp, not in outbound-rtp. A monitor that only reads inbound-rtp is blind to your own upload problems.

  • Polling too often or in a hidden tab. Browsers throttle timers in background tabs, so rates computed from the timestamps stay correct while your interval does not. Do not assume equal spacing.

  • Adapting too fast. Reacting to a single bad sample makes the picture pump between sharp and blurry. Degrade at once, recover slowly, as the adapter does.

  • Treating bandwidth limitation as proof of a bad network. In mesh mode it can only mean you are sending to too many peers; in an SFU call, the server may be capping the layer.

  • Calling setParameters() with a hand-built object. Obtain the parameters with getParameters() and change them in place, and wait for the promise before changing them again.

Version and specification history

  • The original getStats() returned browser-specific arrays; the standard RTCStatsReport map with typed dictionaries replaced it, and the promise form is the only one to use.

  • The statistics identifiers moved out of the main WebRTC specification into their own document, which is why MDN pages link to both. Its 2025 revision reorganised the stats objects to match the underlying data sources and removed the statsended event.

  • GCC and TWCC were never published as RFCs; RFC 8888 (2021) is the standardised congestion feedback format.

  • Browsers expose some fields ahead of the specification or under different names, and Safari and Firefox support fewer fields than Chrome. Feature-test the fields you depend on and keep the indicator working with only round-trip time and packet loss.