On this pageOPENCLOSE
Network quality & getStats
Measure RTT, jitter, packet loss, bitrate, and frames from getStats, then turn those numbers into user-facing truth.
Goal
- 01Read getStats without confusing cumulative counters, current gauges, and rates you must calculate.
- 02Turn RTT, jitter, loss, bitrate, and frame metrics into a diagnosis rather than a dashboard of noise.
- 03Emit privacy-conscious client telemetry that can explain real calls after they happen.
The concept, plainly
`getStats()` returns a `RTCStatsReport`: a map of typed records for inbound RTP, outbound RTP, candidate pairs, transport, data channels, and more. Each record has an id, type, and timestamp. The report is a snapshot. Most values improve when you compute deltas at a known interval instead of treating cumulative counters as rates.
The selected path comes from the transport's `selectedCandidatePairId` and the pair's local and remote candidate ids. RTT from that pair is the network round trip. It is not the same as the application's request latency, and it does not include jitter-buffer delay. Candidate type tells you whether the path is direct or relayed.
Inbound `packetsLost`, `packetsReceived`, `jitter`, and `framesDecoded` describe what one receiver experienced. Outbound `bytesSent`, `packetsSent`, `framesEncoded`, and `qualityLimitationReason` describe what one sender attempted. They are separate directions and can have very different health. Always label direction and source; unlabeled metrics invite wrong conclusions.
Calculate bitrate as `(bytesNow - bytesBefore) * 8 / elapsedSeconds`, with a monotonic clock and protection against counter resets, reconnects, or switching peer connections. Packet loss rate should normally use deltas over the interval as well as the cumulative ratio; a burst matters more than a lifetime average. Jitter is expressed in seconds in stats, so multiply by 1000 for milliseconds.
Quality is subjective. A 150 ms RTT is fine for data but noticeable in conversation; 2% packet loss may be tolerable for video and terrible for remote control. Combine metrics into a small set of states: good, degraded, poor, disconnected. Include the quality limitation reason. CPU-limited video should not be reported as a network outage.
Telemetry needs context: browser, platform, topology, whether TURN was used, media direction, and the selected codec. Do not upload raw SDP, IP addresses, device labels, or full candidate lists by default. Use short-lived aggregate samples with a session id and explicit retention rules. Metrics without context cannot answer 'why did this call fail?'
See it move
Interactive diagram
From stats objects to a decision
Sample on a fixed cadence
Call getStats every one to five seconds for live diagnostics. Faster produces noise; slower misses short bursts. Keep the previous sample per peer generation.
Collect, classify, derive rates, and combine context. A metric is only useful if it changes a decision.
Behind the scenes
Reports are graphs, not flat rows
Inbound RTP points to a codec and a transport. Candidate pairs point to local and remote candidates. The transport points to the selected pair. Follow these ids to answer questions that a single row cannot: is the selected pair relayed, what codec is actually receiving, and is the timestamp fresh?
Cumulative loss hides bursts
A call that lost 1% over ten minutes may have been unusable for thirty seconds in the middle. Sampling deltas gives you the burst. Keep both cumulative and interval views so you can distinguish a chronic bad path from a transient event.
Browser stats are not identical
Field availability and timing vary across Chromium, Firefox, and Safari. Some fields are optional or appear only after media flows. Write diagnostics defensively: tolerate missing fields, label undefined values honestly, and test the browsers you actually support rather than assuming Chromium's report shape.
Code walkthrough
Track the selected pair, derive bitrate from byte deltas, and classify a simple quality state. Replace the logging with your telemetry sink.
type Sample = {at: number;inboundBytes: number;outboundBytes: number;packetsLost: number;packetsReceived: number;jitterMs: number | null;rttMs: number | null;relayed: boolean;bitrateIn: number;bitrateOut: number;};export async function collectSample(pc: RTCPeerConnection, previous: Sample | null): Promise<Sample> {const report = await pc.getStats();const now = performance.now();let inboundBytes = 0, outboundBytes = 0;let packetsLost = 0, packetsReceived = 0, jitterMs: number | null = null;let pairId: string | undefined;report.forEach((stat) => {if (stat.type === "inbound-rtp") {inboundBytes += stat.bytesReceived ?? 0;packetsLost += stat.packetsLost ?? 0;packetsReceived += stat.packetsReceived ?? 0;if (typeof stat.jitter === "number") jitterMs = stat.jitter * 1000;}if (stat.type === "outbound-rtp") outboundBytes += stat.bytesSent ?? 0;if (stat.type === "transport") pairId = stat.selectedCandidatePairId;});const pair = pairId ? report.get(pairId) : undefined;const local = pair ? report.get(pair.localCandidateId) : undefined;const remote = pair ? report.get(pair.remoteCandidateId) : undefined;const elapsed = previous ? Math.max(0.25, (now - previous.at) / 1000) : 0;return {at: now,inboundBytes,outboundBytes,packetsLost,packetsReceived,jitterMs,rttMs: pair?.currentRoundTripTime ? pair.currentRoundTripTime * 1000 : null,relayed: local?.candidateType === "relay" || remote?.candidateType === "relay",bitrateIn: elapsed ? Math.max(0, (inboundBytes - (previous?.inboundBytes ?? inboundBytes)) * 8 / elapsed) : 0,bitrateOut: elapsed ? Math.max(0, (outboundBytes - (previous?.outboundBytes ?? outboundBytes)) * 8 / elapsed) : 0,};}export function qualityState(sample: Sample) {if (sample.rttMs === null) return "connecting";const loss = sample.packetsReceived + sample.packetsLost > 0? sample.packetsLost / (sample.packetsReceived + sample.packetsLost): 0;if (sample.rttMs > 400 || loss > 0.08) return "poor";if (sample.rttMs > 200 || loss > 0.02 || (sample.jitterMs ?? 0) > 60) return "degraded";return "good";}
Do and don't
Do
Label direction and source
Inbound loss is the receiver's view; outbound target bitrate is the sender's intent. They answer different questions.
Use deltas for rates
Bitrate and interval loss require two samples and elapsed time. Store the previous sample per connection id.
Include quality limitation
CPU-limited video is not a network problem. This one field saves hours of misplaced debugging.
Keep telemetry small and contextual
Aggregate metrics, strip addresses and labels, include topology and browser, and define a retention window.
Don't
Don't treat cumulative bytes as speed
A large bytesSent value only means the call has been running. Compute a rate over a known interval.
Don't compare directions
A healthy outbound stream does not prove the inbound path is healthy. Sample both separately.
Don't upload raw getStats dumps
They can contain IP addresses, candidate details, and device-adjacent metadata. Aggregate and redact.
Don't show a number without a decision
If a metric does not change support, adaptation, or user guidance, it is decoration.
Practice
Hands-on lab
Read a live report
The lab parses real getStats into RTT, jitter, loss, bitrate, frame size, FPS, packets, bytes, and the selected pair. Use it as your reference implementation.
- 1Connect loopback and record three samples a second apart. Explain why bitrate changes while bytes only grow.
- 2Find the selected pair and decide whether it is direct or relayed.
- 3Set a candidate filter that prevents a path and observe which fields stop updating.
- 4After disconnecting, explain which counters would be misleading if carried into the next session.
Solo? Loopback is enough. Both peers are local, so the network numbers are boring, but the graph structure, counters, and path selection are real.
Quick quiz
Knowledge check
0 of 5 answered
- 01
Which value is cumulative rather than a current rate?
- 02
How do you find which candidate pair carries media?
- 03
qualityLimitationReason is 'cpu'. What does that suggest?
- 04
Why is a lifetime average packet-loss rate insufficient?
- 05
Which is the safest telemetry choice?
Key takeaways
- getStats is a graph of records; read the selected pair and the direction you care about.01
- Counters need deltas, and deltas need a monotonic clock and a generation check.02
- qualityLimitationReason separates CPU problems from network problems.03
- Context and privacy are part of telemetry design, not an afterthought.04
Common errors and fixes
Terms in this lesson
Done with this lesson?
Mark it complete, then keep the thread.