"The call was bad" is the whole bug report, most of the time. No timestamp, no network, no idea whether the problem was the user's Wi-Fi, a relay, the server or the camera.
The browser already knows. Every WebRTC session continuously measures packet loss, jitter, round-trip time, frozen video, why the sender lowered quality and whether media is being relayed. getStats() is how you read it. Collecting it across real users in production, rather than in a debugging tab, is what turns a complaint into evidence.
What getStats actually returns
Calling getStats() on a peer connection returns a report: a map of stats objects, each with a type, an ID and a timestamp, and IDs that link related objects together. The W3C specification, a Candidate Recommendation Draft dated 25 September 2025, defines the types, including:
Type | What it describes |
|---|---|
inbound-rtp / outbound-rtp | One media stream being received or sent |
remote-inbound-rtp / remote-outbound-rtp | What the other side reports about your streams |
candidate-pair | A pair of network addresses ICE tested; one is selected for the call |
local-candidate / remote-candidate | The addresses themselves, including their type |
transport | The connection carrying media, and which candidate pair it uses |
codec, media-source, media-playout | Encoding, the capture source and audio playout |
The fields that answer real questions
Most of the report is noise for monitoring. A short list answers the questions incidents actually ask:
Question | Where to look | Notes |
|---|---|---|
Is this call going through a TURN relay? | Selected candidate-pair → its local or remote candidate's type | "relay" means TURN |
How far away is the other end? | candidate-pair.currentRoundTripTime | Seconds |
How much bandwidth does the sender think it has? | candidate-pair.availableOutgoingBitrate | The bandwidth estimation output |
Why did my video quality drop? | outbound-rtp.qualityLimitationReason | none, cpu, bandwidth or other |
Are packets being lost? | inbound-rtp.packetsLost | Cumulative, as RFC 3550 defines it |
Is arrival timing unstable? | inbound-rtp.jitter | Seconds |
Did video freeze? | inbound-rtp.freezeCount, totalFreezesDuration | Count, and seconds frozen |
Were frames thrown away? | inbound-rtp.framesDropped | Before decode, or late for display |
How much playout delay is there? | jitterBufferDelay ÷ jitterBufferEmittedCount | Average seconds per frame or sample |
qualityLimitationReason is the most underused field. When a user's outgoing video drops to a low resolution, it tells you whether their CPU or their network made that call. That one value usually decides whether the fix is on your side or theirs.
Counters, not rates
Most of these values are running totals since the stream started. A packetsLost of 1,200 means nothing on its own; it might be the first ten seconds or the last ten minutes.
The specification is explicit about how to use them: compute averages "by calling getStats() twice, taking the difference of the two sums and dividing by the difference of the two counts", and for time-based values such as byte counts, divide by the difference in timestamps. In practice that means every sample is compared with the previous one for the same object ID:
const prev = new Map();
async function sample(pc) {
const report = await pc.getStats();
report.forEach(s => {
if (s.type !== 'inbound-rtp' || s.kind !== 'video') return;
const p = prev.get(s.id);
if (p) {
const lost = s.packetsLost - p.packetsLost;
const recv = s.packetsReceived - p.packetsReceived;
const lossRate = lost / Math.max(1, lost + recv);
const kbps = 8 * (s.bytesReceived - p.bytesReceived) / (s.timestamp - p.timestamp);
emit({ id: s.id, lossRate, kbps, freezes: s.freezeCount - p.freezeCount });
}
prev.set(s.id, s);
});
}
Two details matter. Objects come and go, so a stream ID you saw last time may be gone and a new one may appear. Treat a missing previous sample as "no rate yet", not as zero. And a counter that goes down means the object was replaced; reset rather than recording a negative.
Designing a WebRTC getStats production collector
None of what follows is in a standard. These are design choices, and the right values depend on your traffic and budget.
- Sample on a fixed interval, summarise on the client. Polling every few seconds and sending a compact summary keeps payloads small. Send raw reports only for a small sample of sessions, or on demand.
- Tag every record. Session ID, your own participant ID, browser and version, SDK version, platform and network type. Without tags, evidence cannot be compared.
- Keep a per-session summary. Relayed or not, worst round-trip time, total loss rate, seconds frozen, and the share of time each quality-limitation reason was active. That summary is what support and incident reviews actually read.
- Leave addresses out. Candidate stats include IP addresses. Record the candidate type, not the address, unless you have a specific, documented reason.
- Expect missing fields. Implementations differ, and the spec itself says fields that reveal hardware "MUST NOT exist unless exposing hardware is allowed". A collector that breaks on a missing field will break on the first unfamiliar browser.
If your collector predates Chrome 117
Chrome removed the old callback-based getStats() in version 117, with an origin trial extending it to version 121. Collectors written against it need porting. Chrome's migration guide maps the old names to the standard ones:
Legacy (goog-prefixed) | Standard |
|---|---|
googRtt | candidate-pair.currentRoundTripTime |
googAvailableSendBandwidth | candidate-pair.availableOutgoingBitrate |
googTargetEncBitrate | outbound-rtp.targetBitrate |
googCodecName | codec.mimeType |
One change catches people out: with simulcast, the standard API reports a separate outbound-rtp object for each layer, where the legacy API showed one. A collector that assumes one outbound video stream per sender will under-count what is actually being sent.
What client stats cannot tell you
getStats sees one side of one connection. It cannot see inside your media server, it cannot see another user's network, and on a call through an SFU, a remote participant's problem shows up in your stats only as its effect on the streams you receive. Pair client evidence with server-side metrics and logs before deciding where a problem sits, and treat any single metric as a clue, not a root cause.
The Bottom Line
Every WebRTC session already measures what you need to diagnose it. getStats exposes it as typed, linked objects defined by the W3C spec; most values are cumulative, so rates come from deltas between samples. Collect a small set of fields, sample on an interval, tag everything, leave addresses out, and keep a summary per session. Then "the call was bad" arrives with evidence attached.
What's Next
Evidence matters most at 2am. For what happens on each path when video fails, see who gets paged when video fails.
Frequently Asked Questions
How do I use WebRTC getStats in production?
Sample getStats() on a fixed interval, compute rates from the difference between consecutive samples, summarise on the client and send compact records tagged with session, browser and SDK version. Keep raw reports for a small sample of sessions only.
How can I tell if a WebRTC call is using TURN?
Find the selected candidate pair in the stats report and check the candidate type of its local or remote candidate. A type of "relay" means media is going through a TURN server.
Why are getStats values so large?
Most are cumulative counters since the stream started, such as packets lost or bytes received. Subtract the previous sample from the current one and divide by the time between them to get a rate.
What does qualityLimitationReason mean?
It reports why the sender is limiting its outgoing video quality: none, cpu, bandwidth or other. It is the quickest way to tell whether a low-quality stream is caused by the sender's device or its network.
Does the old getStats API still work?
Not in current Chrome. The legacy callback-based getStats() was removed in Chrome 117, with an origin trial extending it to 121. Collectors using goog-prefixed names need to move to the standard stats objects.
Can I see server-side problems in getStats?
Only indirectly. getStats describes the browser's own connections, so media-server or relay problems appear as their effects on the streams you send and receive. Platforms that keep the media path and TURN on infrastructure you control, such as Samvyo, which is based on SFU architecture, let you put server-side metrics next to client stats for the same session.