§ 6.4  Module 6 — Surviving the Network: Loss, Congestion, and Jitter

Reading the Instruments: getStats()

When you can measure what you are speaking about, and express it in numbers, you know something about it.— William Thomson, Lord Kelvin, 1883

By the end of this lesson

  • Navigate the RTCStatsReport graph and its id-reference joins
  • Compute bitrates, packet rates, and fps from cumulative counters via timestamped deltas
  • Locate RTT (remote-inbound-rtp vs candidate-pair STUN) and jitter (seconds!) correctly
  • Interpret qualityLimitationReason, framesDropped, retransmittedPackets, nackCount/pliCount

Historical note

The X-ray machine came before the anatomy textbook. In 2012, the Google engineers wiring WebRTC into Chrome needed to see inside their own black box, so they shipped themselves a debugging page: chrome://webrtc-internals, a live dump of every RTCPeerConnection in the browser — every API call, every negotiated parameter, and time-series graphs of hundreds of counters. The counters were Chrome-private, prefixed goog, every value a string, and the units ad hoc: googRtt and googJitterReceived reported milliseconds, undocumented and changeable without notice. That was the problem the next iteration had to solve, because an industry was already building on the numbers — Varun Singh founded callstats.io in Helsinki in 2013 to sell call-quality dashboards assembled from exactly these counters, and every Chrome update threatened to break them. The W3C's answer was to make the instrument programmable and contractual: Identifiers for WebRTC's Statistics API, the webrtc-stats document edited by Harald Alvestrand (Google), Varun Singh, and Henrik Boström (Google), defined a typed dictionary for every statistic, with SI units. Chrome 58 (April 2017) shipped the spec's promise-based getStats(), and the legacy callback form with its goog menagerie was finally removed in Chrome 117 (2023). The genealogy left a scar you will meet in this lesson: a generation of dashboards learned jitter in milliseconds from googJitterReceived, and the standard reports seconds. The metrics of getStats and webrtc-internals look self-explanatory and are not — cumulative counters masquerade as rates, jitter changes units between wire and API, and round-trip time lives in two different places.

6.4.1The report is a graph, not a list

Call await pc.getStats() and you receive an RTCStatsReport: a maplike collection of small dictionaries, each carrying three universal members — a unique id string, a timestamp, and a type that names which dictionary shape you are holding. The types you will read daily: outbound-rtp, one per RTP stream you send (so three of them under simulcast, one per layer); inbound-rtp, one per stream you receive; remote-inbound-rtp and remote-outbound-rtp, your reflection in the far peer's mirror, distilled locally from the RTCP reports it sends back; media-source, the track before the encoder touches it; codec, the negotiated payload formats; transport, the DTLS/ICE bundle underneath everything; and candidate-pair, local-candidate, remote-candidate, the connectivity lattice from Module 3.

What makes the report navigable is that these dictionaries reference each other by id. An outbound-rtp entry carries a mediaSourceId, a codecId, a transportId, and a remoteId naming its remote-inbound-rtp twin; the transport carries a selectedCandidatePairId; the pair carries localCandidateId and remoteCandidateId. The report is a relational database serialized as a Map, and every question you will ever ask of it is a join: report.get(stat.someId). Nothing is nested; everything is flat and keyed. Learn the graph once — Figure 6.4.1 draws the sender's half — and any statistic is three hops away.

media-source outbound-rtp remote-inbound-rtp mediaSourceId remoteId localId codec transport codecId transportId candidate-pair local-candidate remote-candidate selectedCandidatePairId localCandidateId remoteCandidateId every arrow is a string id — resolve it with report.get(id); the receive side mirrors this graph through inbound-rtp
Figure 6.4.1 — The sender's half of the stats graph. A flat Map of dictionaries joined by id strings; every diagnostic question is a walk along these arrows.

6.4.2The delta discipline

Here is the trap that has broken more dashboards than any other: almost nothing in the report is a rate. bytesSent, packetsSent, framesEncoded, packetsLost, nackCount — all cumulative counters, monotonically climbing since the stream began. The design is deliberate and it is the right one: a counter survives a missed poll (you lose resolution, never information), it cannot double-count, and any consumer can derive a rate over whatever window it likes. But it means the raw number is nearly meaningless on its own. A bytesSent of 52,911,437 tells you the call has been running a while; it says nothing about whether video is flowing now.

The discipline: keep the previous sample, subtract, and divide by the delta of the report's own timestamp field — a DOMHighResTimeStamp in milliseconds. Never divide by your polling interval. You asked setInterval for 1,000 ms; the event loop delivered 1,007, or 1,340 under load, and the report itself was composed at some instant you do not control. The timestamp member records when the counters were actually read, and it is the only denominator that makes the arithmetic honest:

const prev = new Map();                      // ssrc → last sample
async function sample(pc) {
  const report = await pc.getStats();
  for (const s of report.values()) {
    if (s.type !== 'outbound-rtp' || s.kind !== 'video') continue;
    const p = prev.get(s.ssrc);
    if (p) {
      const dt   = (s.timestamp - p.timestamp) / 1000;      // ms → s
      const kbps = (s.bytesSent - p.bytesSent) * 8 / dt / 1000;
      const fps  = (s.framesEncoded - p.framesEncoded) / dt;
      render(s.ssrc, kbps, fps);
    }
    prev.set(s.ssrc, s);
  }
}
setInterval(() => sample(pc), 1000);

Listing 6.4.1 — the delta discipline. Rates come from consecutive-sample subtraction over the report's own timestamps, never from the counters or the intended polling interval.

The same discipline yields packet rate from packetsSent, loss rate from packetsLost against packetsReceived, and encoder framerate from framesEncoded. Comparing joined entries across one report is equally revealing without any deltas at all: when media-source says the camera delivers 1280 × 720 at 30 fps and outbound-rtp says frameWidth 640, frameHeight 360, the gap between them is adaptation, measured — the encoder is shedding load, and § 6.4.4 tells you why.

Predict before you compute — both questions below are one subtraction away.

Check your understanding

Auto-graded check

3 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m6-l4-check1)

A stats pipeline fails silently twice: a Map that stringifies to {}, and names that vanishapi

getStats() resolves with a Maplike object, so JSON.stringify(report) returns {} with no error — teams have shipped telemetry that faithfully transmitted empty objects for months. Then the names churn: the track stats type was removed with its fields redistributed into media-source, inbound-rtp, and outbound-rtp; sender and receiver types went away; mediaType gave way to kind and trackId to trackIdentifier. Neither failure is loud: dashboards render zero rather than erroring.

What to do

Convert with Object.fromEntries(report) or [...report.values()] and assert non-empty before sending — worth a unit test, since nothing else will catch it. Then alert on missing fields, not just bad values: a metric that goes from populated to absent should page someone. Note that webrtc-internals still displays legacy goog* names that no longer exist in the standard API.

6.4.3Two round-trip times and one jitter

Ask "what is the RTT?" and the report answers twice, from two different instruments. The first lives in remote-inbound-rtp.roundTripTime and is measured by RTCP: your sender stamps a Sender Report, the far end's Receiver Report echoes when it last saw one (LSR) and how long it sat on it (DLSR), and arrival time minus both is the round trip (RFC 3550 §6.4.1). This is the RTT of the media path for that specific stream, refreshed at RTCP cadence — roughly once a second. The second lives in candidate-pair.currentRoundTripTime and is measured a layer down, by the STUN connectivity checks that ICE keeps sending as keepalives and consent probes (RFC 7675): the transport's RTT, per candidate pair, independent of any media. They usually agree; when they diverge, the divergence is itself a symptom — RTCP RTT climbing while STUN RTT holds steady points at remote endpoint distress rather than the network. Both are reported in seconds, and both come with averaging companions (totalRoundTripTime ÷ roundTripTimeMeasurements and responsesReceived) when a single reading is too noisy.

Jitter is worse, because it changes units between the wire and the API. On the wire, the interarrival jitter carried in RTCP reports is expressed in RTP timestamp ticks — the media clock, 90 kHz for video, 48 kHz for Opus. The jitter member of inbound-rtp (and of remote-inbound-rtp) is that same quantity divided by the codec's clock rate: seconds. A reading of 0.045 is 45 milliseconds of interarrival variation — enough that the jitter buffer of § 6.3 is holding a deep cushion and NetEQ is time-stretching to hide it. Read it as milliseconds and you will conclude the network is pristine while your users hear soup.

A common error

"getStats jitter is in milliseconds." It is in seconds — every duration in the standardized stats dictionaries is SI seconds unless the name says otherwise. The confusion has a specific ancestry: Chrome's legacy googJitterReceived did report milliseconds (as a string, no less), and dashboards written against it before Chrome 58's standardized getStats() carried the habit forward. The failure mode is silent: 0.045 rendered on a chart scaled for milliseconds looks like a flawless network, and the alert that should have fired at 45 ms never does. The wire form is different again — RTP timestamp units, so 45 ms of jitter on an Opus stream travels as 2,160 ticks. Three representations, one quantity; only the units differ.

Interactive lab bench

Reading the instruments — getStats() as the graph it is

A 3D instrument you drive yourself, one variable at a time. It needs JavaScript and WebGL, so it is not shown in this static copy of the page.

6.4.4qualityLimitationReason and the adaptation ladder

Module 6 has argued that the encoder's bitrate is not its own: the congestion controller hands down a target, and the encoder obeys. outbound-rtp is where that obedience becomes visible. The qualityLimitationReason member says, in one word, why the encoder is not producing the quality the track could support: 'none' (it is), 'bandwidth' (the target bitrate is the ceiling), 'cpu' (the machine cannot encode fast enough), or 'other'. Its companion qualityLimitationDurations is a map of seconds spent in each state — the call's whole history of struggle in four numbers — and qualityLimitationResolutionChanges counts how many times the encoder actually moved a rung. The word choice matters for remediation: 'bandwidth' indicts the network or a configured cap, and the fix is congestion-side; 'cpu' indicts the endpoint — a laptop on battery, a browser encoding three simulcast layers in software — and no amount of bandwidth will help it.

The surrounding counters tell you what the struggle is costing. retransmittedPacketsSent is the RTX tax paid to repair loss; nackCount and pliCount on outbound-rtp count the NACK and PLI messages of the RTCP feedback machinery this sender has received — the direction reads backwards until you internalize that feedback about your sending arrives inbound. On the receive side, inbound-rtp.framesDropped counts frames that arrived but were discarded before display, typically for arriving too late to render — loss the network never saw. A rising pliCount with a rising retransmittedPacketsSent is a lossy path; a rising framesDropped with clean packet counts is a late path, and § 6.3's buffer is where the lateness is being paid.

When the encoder must shed load, what it sacrifices is a policy you control. The degradationPreference member of the sender's parameters (set through setParameters) chooses the ladder: 'maintain-framerate' walks resolution down and keeps motion smooth; 'maintain-resolution' walks framerate down and keeps pixels sharp; 'balanced', the default, alternates. The track-level contentHint property declares what the pixels are'motion', 'detail', or 'text' for video — so the pipeline can choose codecs, tools, and ladders to match; Chrome treats screen-capture tracks and tracks hinted 'detail' or 'text' as resolution-precious. The stakes are concrete for the screen-share case: § 5.3 showed how 4:2:0 chroma subsampling already bleeds red text at full resolution; let the adaptation ladder halve the resolution under congestion and the glyphs dissolve entirely. A code review over a congested link wants contentHint = 'text' and 'maintain-resolution': fifteen sharp frames per second beat thirty blurred ones when the content is a diff.

target bitrate falls — encoder must shed load 'maintain-framerate' 'maintain-resolution' 1280×720 @ 30 960×540 @ 30 640×360 @ 30 1280×720 @ 30 1280×720 @ 15 1280×720 @ 7 motion stays smooth — text goes blurry motion goes choppy — glyphs stay sharp
Figure 6.4.2 — Two ways down the adaptation ladder. degradationPreference chooses the axis of sacrifice; qualityLimitationResolutionChanges counts the left ladder's steps, and the media-source vs outbound-rtp gap measures either.
Timestamps have different epochs; use your own clockbrowser

RTCStats.timestamp is a DOMHighResTimeStamp, but what it is relative to has varied by browser and by stat type — some values look like Unix epoch milliseconds, some like time-since-time-origin. Comparing them against Date.now(), or across two participants' reports, produces nonsense offsets. Cross-participant correlation additionally suffers from ordinary wall-clock skew of seconds between devices.

What to do

Record your own performance.now() (or a server-corrected timestamp) alongside each snapshot and compute rates from that. For cross-participant timelines, have the server stamp arrival time on each beacon and store the measured client offset at join.

6.4.5Finding the pair that carries the call

The last join is the one most often botched. A live report contains many candidate-pair entries — every pairing ICE ever checked, frozen at various states — and exactly one of them is carrying your media. The tempting heuristics are all wrong. Picking the pair with the most bytesSent fails after a network change: the stale pair keeps its byte totals forever, and the fresh pair — perhaps a TURN relay pair adopted when WiFi dropped — starts from zero. Filtering on nominated === true fails too, because nomination is sticky history, not present tense: after an ICE restart or renomination, several succeeded pairs can carry the flag. The authoritative answer is the transport's own declaration: find the transport entry — the same dictionary that reports the DTLS handshake state — and resolve its selectedCandidatePairId. One join, no guessing.

Once you hold the right pair, it is the richest dictionary in the report: currentRoundTripTime from § 6.4.3, availableOutgoingBitrate straight from the bandwidth estimator, request/response counters for the STUN conversation, and the two candidate ids that tell you — via one more join — whether this call is host-to-host on a LAN or relayed through a TURN server two countries away. That, plus the delta discipline, plus the graph, is the whole craft. A word of calibration before the problems: connectionState === 'connected' tells you the transport is up, and nothing more; whether video is actually flowing is a question only inbound-rtp's climbing counters can answer. The person others call when "the video is bad" is simply the person who knows which dictionary to open, which counter to subtract, and which of two RTTs to believe.

Problems for § 6.4Check your understanding

Two items: build the poller every production WebRTC service contains, then prove you know where the numbers live and what units they wear.

Lab 1

The live stats poller

Graded in the browser against 3 assertions; the editor and harness require JavaScript.

Check your understanding

Auto-graded check

5 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m6-l4-ex2)

Field quirks

What the specifications say, and what the browsers actually do, are two different documents. These are the divergences that cost production teams their weekends.

getStats() is main-thread work and scales with receiversperf

On a two-party call the call is cheap. On a 50-participant SFU connection there are hundreds of stats objects and a full getStats() can take 10–40 ms of main-thread time; polled every second, that is a measurable jank source in exactly the sessions that are already under load. RTCPeerConnection is not available in workers, so you cannot move it off-thread.

What to do

Poll every 2–5 s rather than every second, and use the track-selector form getStats(track) when you only need one stream. Do the aggregation in a worker by posting the plain-object snapshot, and never poll from a rAF callback.

webrtc-internals holds information getStats cannot give youdeployment

chrome://webrtc-internals records the full event timeline — every signaling state change, every candidate pair transition, the actual SDP of every exchange — none of which is available through getStats(). Its "Create Dump" button is the only way to capture history rather than a snapshot, and enabling diagnostic packet and event recording writes an RTC event log that rtc_event_log_visualizer can render into bandwidth-estimate and pacer graphs. The page must be open before the call to capture it.

What to do

Make "open webrtc-internals in a second tab, reproduce, then Create Dump" the first line of your support playbook. For hard bandwidth-estimation bugs, the event log is the only artifact that shows the estimator's internal decisions.

The Impossible Call · Module 6, Lesson 4