§ 6.4 Module 6 — Surviving the Network: Loss, Congestion, and Jitter
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
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.
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.
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)
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.
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.
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.
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.
degradationPreference chooses the axis of sacrifice; qualityLimitationResolutionChanges counts the left ladder's steps, and the media-source vs outbound-rtp gap measures either.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.
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.
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.
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)
What the specifications say, and what the browsers actually do, are two different documents. These are the divergences that cost production teams their weekends.
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.
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.
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.
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