"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.