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-connectionanddata-channelreports; 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, andsender.getStats()orreceiver.getStats()do the same for oneRTCRtpSenderorRTCRtpReceiver. 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
undefinedbefore 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 |
|---|---|
|
One sent RTP stream (one per encoding when simulcast is used): |
|
One received RTP stream: |
|
What the other side says it received from your |
|
What the other side says it sent (from RTCP sender reports): used to compute round-trip time on the receive side. |
|
The local capture source feeding a sender: |
|
One ICE candidate pair: |
|
The DTLS/ICE transport: |
|
Candidate details: |
|
|
|
Counts of opened and closed data channels, and per channel messages and bytes sent and received. |
|
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 |
|---|---|---|
|
|
Round-trip time of the ICE connectivity checks, in seconds. Under 0.15 is comfortable for conversation; over 0.4 makes turn-taking awkward. |
|
|
Round-trip time measured by RTCP. It tracks the media path more closely than the ICE checks. |
|
|
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. |
|
|
Cumulative counts. The loss percentage over an interval is the difference in lost divided by the difference in lost
plus received. |
|
|
Frames the receiver decoded or received but did not show, and the number of visible freezes. These describe what the viewer actually saw. |
|
|
Delivered frame rate and size. A sender whose |
|
|
The encoder’s current target in bits per second, after congestion control and your |
|
|
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. |
|
|
Why the encoder is producing less than the source offers: |
|
|
Divide the first by the second for the average time frames or samples waited in the jitter buffer. |
Source: MDN: RTCStatsReport
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.
Source: W3C: RTCInboundRtpStreamStats
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 thetransport-ccfeedback 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. Usemaintain-resolutionfor screen sharing of text andmaintain-frameratefor people. -
track.contentHint(motion,detail,text) tells the encoder what the content is. -
active: falseon 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
visibilitychangetohidden, not onunload: 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.
Source: MDN: Navigator.sendBeacon()
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;
bytesReceivedis bytes. A jitter of 0.03 is 30 ms. -
Assuming a field exists. Absent fields are normal:
availableOutgoingBitratebefore the first estimate, video fields on an audio-only call,framesDroppedon 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 inoutbound-rtp. A monitor that only readsinbound-rtpis 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
bandwidthlimitation 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 withgetParameters()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 standardRTCStatsReportmap 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
statsendedevent. -
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.
References
-
RFC 8888: RTP Control Protocol (RTCP) Feedback for Congestion Control
-
RFC 3550: RTP: A Transport Protocol for Real-Time Applications
-
Internet-Draft: A Google Congestion Control Algorithm for Real-Time Communication (expired)
-
Internet-Draft: RTP Extensions for Transport-wide Congestion Control (expired)