Reading chrome://webrtc-internals Dumps

chrome://webrtc-internals is the fastest path from “the call failed” to “ICE never nominated a pair.” This guide is part of the Cross-Browser WebRTC Debugging guide, and it walks through capturing a dump, reading the live getStats timeline graphs, interpreting the candidate-pair, ICE, and DTLS event log, and exporting the JSON so a teammate can reproduce your analysis without your machine.

Context & Trade-offs

The page records only peer connections created after it loads, so opening it mid-failure gives you nothing — open it in a second tab before you start the call. Once attached, it samples getStats() roughly once per second and renders every numeric stat as a live graph, which is enough resolution to see a candidate pair’s round-trip time climb or RTP throughput collapse within a few seconds.

The trade-off versus pure getStats() polling in your app is fidelity versus portability: the dump captures the full API call log (every createOffer, addIceCandidate, setRemoteDescription) with timestamps that your application logs usually lack, but it exists only in Chrome — WebKit needs the separate workflow described in debugging WebRTC on Safari and iOS WKWebView. Use it for Chrome triage and a programmatic pipeline built on interpreting getStats() for congestion signals for cross-engine parity. The dump is also large — a two-minute call can produce several megabytes of JSON — so capture deliberately rather than leaving it running.

Chrome dump versus an application-side getStats pipeline Six-row comparison matrix: timestamped API call log, sampling cadence, engine coverage, CI scriptability, survival after tab close, and payload size, scored for the webrtc-internals dump and for a programmatic getStats pipeline. Fidelity vs portability: dump against stats pipeline Capability chrome://webrtc-internals app getStats pipeline Timestamped API call log complete absent Sampling cadence ~1 s, automatic 1 s, you schedule Engine coverage Chrome only every engine Scriptable in CI no yes Survives tab close only if exported shipped to logs Payload, 2-minute call several MB JSON a few KB counters Chrome triage: read the dump. Cross-engine parity: keep the pipeline.
What the dump gives you that a stats pipeline cannot, and the reverse.

What the page is actually recording

The dashboard is a plain browser UI page fed by an observer that the browser process registers at RTCPeerConnection construction time. That observer forwards two independent streams: a discrete update log — every method call, state change and candidate, each stamped to the millisecond — and a periodic getStats() snapshot. Two consequences fall straight out of that design. Because the observer is attached in the constructor, a connection that already existed when you opened the tab has no observer and can never be back-filled, which is why “open it first” is a hard rule rather than a habit. And because the series accumulate in the internals page’s own JavaScript heap rather than on disk, the whole history dies with the tab and grows without bound while it lives: a twelve-participant room left open for an hour can push that tab past a gigabyte and get it killed by the OS memory manager, taking your evidence with it. On soak tests, export every 10–15 minutes and treat each file as a checkpoint.

Most of what you read is derived rather than reported. Chrome stores raw cumulative counters — bytesSent, packetsLost, framesEncoded — and the page differentiates consecutive samples to draw the rate series, which is why their names carry a _in_bits/s or /s suffix. A derived series needs two samples before it can plot anything, so its first point is arithmetic noise; ignore the opening second of every rate graph before you conclude that a stream started slowly. Any name without a suffix is the counter itself, which only ever climbs — a flat counter is the signal, a falling one means you are looking at a different SSRC.

Older dumps speak a second dialect. Until Chrome 117 removed the non-standard callback-based getStats, the page carried a “Read stats from” toggle and could emit goog-prefixed names such as googRtt, googFrameRateSent and googCurrentDelayMs. A dump full of those predates M117; when you compare it against a modern one, remember that googRtt is milliseconds while currentRoundTripTime is seconds. That factor of 1000 is the single most common misreading when an old bug report is reopened against current Chrome.

Minimal Runnable Implementation

You cannot script the dashboard, but you can emit a matching trace from your app so its 1-second samples line up with the graphs, which makes the dump far easier to read.

// Emit app-side markers that align with webrtc-internals' ~1 s sampling cadence.
// Reading the dump next to these logs lets you map a graph spike to a code event.
function attachInternalsAlignedLogging(pc) {
  // Log every signalling/ICE transition the dump's event list also records.
  pc.addEventListener('iceconnectionstatechange',
    () => console.log('[ice]', Date.now(), pc.iceConnectionState));
  pc.addEventListener('connectionstatechange',
    () => console.log('[conn]', Date.now(), pc.connectionState));

  setInterval(async () => {
    const report = await pc.getStats();
    for (const s of report.values()) {
      if (s.type === 'candidate-pair' && s.nominated) {
        // These three values are the headline graphs in the dump.
        console.log('[pair]', Date.now(), {
          state: s.state,                      // 'succeeded' once nominated
          rtt: s.currentRoundTripTime,         // matches the RTT graph (seconds)
          bytesSent: s.bytesSent               // matches the throughput graph
        });
      }
    }
  }, 1000);
}

Because both sides tick at roughly one second, every graph point in the dump has a console line logged inside the same sample window, and the sub-second transitions that fall between graph points still show up in your log.

Aligning application console markers with the dump's one-second samples A two-second timeline: the dump records graph samples at 0, 1 and 2 seconds, while the application logs ICE and connection state markers at 0.12 s, 0.54 s and 0.90 s plus candidate-pair samples that land on the same one-second boundaries as the graph points. App markers aligned to the dump's 1 s samples chrome://webrtc-internals graph samples 0.0 s 0.5 s 1.0 s 1.5 s 2.0 s app console markers [ice] checking [ice] connected [conn] connected [pair] rtt 0.032 [pair] rtt 0.034 Green markers land on graph points; purple markers are transitions the graphs cannot show.
One-second graph samples with the console lines that fall in each window.

Reading an exported dump programmatically

The export is one JSON object, and its shape is simple enough that a twenty-line script beats scrolling the UI once you have more than two dumps to compare. Each peer connection is keyed by process and connection id; its stats object holds one entry per "<statId>-<statName>" series, whose values field is a JSON-encoded array of the samples the graph drew.

// Summarise round-trip time out of a Create Dump export.
// Shape: { getUserMedia: [...], PeerConnections: { "<pid>-<lid>": { updateLog, stats } } }
function summariseRtt(dump) {
  for (const [id, pc] of Object.entries(dump.PeerConnections)) {
    for (const [series, entry] of Object.entries(pc.stats)) {
      if (!series.endsWith('-currentRoundTripTime')) continue;
      // values is a JSON string, not an array — parse it before use.
      const samples = JSON.parse(entry.values).filter(v => typeof v === 'number');
      if (!samples.length) continue;            // pair never completed a check
      const sorted = [...samples].sort((a, b) => a - b);
      console.log(id, series, {
        from: entry.startTime,                  // anchors the graph's x-axis
        samples: samples.length,
        medianMs: Math.round(sorted[sorted.length >> 1] * 1000),
        p95Ms: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 1000)
      });
    }
  }
}

The updateLog array on the same object is the machine-readable form of the event list you read in the UI: {time, type, value} triples where type is the API call or event name and value its argument text. Diffing the updateLog of a working client against a failing one is usually faster than staring at either alone — the first divergent type names the layer that broke, and its time gives you the millisecond to correlate against server logs.

Reproduction Steps & Debugging Log Patterns

  1. Open chrome://webrtc-internals in a new tab, then start your call in the original tab. The connection appears as a collapsible block keyed by page URL and a numeric id.
  2. Expand the block. The top section is the API event log — a chronological list of method calls. A healthy negotiation reads createOffer → setLocalDescription → setRemoteDescription (answer) → addIceCandidate (×N). A gap here is a signalling fault, not a network one.
  3. Scroll to the stat graphs. Find the candidate-pair group and watch state: it should move in-progress → succeeded. The currentRoundTripTime graph should settle to a flat line; a pair stuck in-progress with no RTT means connectivity checks are failing.
  4. Inspect the transport group for DTLS. dtlsState should reach connected; if ICE succeeded but DTLS stays connecting, you have a certificate or handshake problem, not a NAT one — follow the fault tree in debugging DTLS handshake failures.
  5. Click Create Dump at the top of the page and save the JSON for export.
Annotated layout of a chrome://webrtc-internals connection block Mock of the webrtc-internals page: a connection block header, the API event log listing createOffer through addIceCandidate, the candidate-pair stat graph with state and round-trip time, the transport group with dtlsState, and the Create Dump button, each labelled with the numbered reading step it belongs to. chrome://webrtc-internals PeerConnection — app.example.com [id 12] API event log createOffer setLocalDescription setRemoteDescription (answer) addIceCandidate x14 Stats graph — candidate-pair state: in-progress to succeeded currentRoundTripTime 32 ms transport — dtlsState: connected Create Dump 1 Attach before the call starts 2 A gap here is a signalling fault 3 Pair must reach succeeded 4 A DTLS stall is not a NAT issue 5 Export before closing the tab
Where each numbered reading step lives on the page.

Expected healthy console output alongside the graphs:

// [ice] 1718900000123 checking
// [ice] 1718900000540 connected           // ICE nominated a pair
// [conn] 1718900000901 connected           // DTLS + ICE both up
// [pair] 1718900001002 { state: 'succeeded', rtt: 0.032, bytesSent: 48210 }

A failing session instead shows iceConnectionState looping checking → disconnected while every candidate-pair graph reports state: 'failed' and currentRoundTripTime never appears. Cross-check that pattern against your ICE Candidate Gathering & Filtering policy — a filter dropping srflx candidates leaves only host pairs that cannot traverse NAT — and read the loop itself against disconnected vs failed ICE states, because a dump that only ever reaches disconnected is a recoverable path change rather than a dead one.

Work the dump top-down: the event log rules out signalling, the candidate-pair graphs rule out the network path, and the transport group rules out the handshake. Only after all three are clean is the fault in the media itself.

Which layer failed, read top-down from the dump Decision tree: if the API event log ends before setRemoteDescription the fault is signalling; otherwise if the candidate-pair is stuck in-progress with no round-trip time the ICE path is blocked; otherwise if dtlsState is stuck at connecting it is a handshake fault; if all three are clean the fault is in the media graphs. Dump shows the call never connects API event log ends before setRemoteDescription? yes Signalling fault Fix offer/answer delivery, not the network no candidate-pair stuck in-progress, no currentRoundTripTime? yes ICE path blocked Check srflx filtering and TURN reachability no transport shows dtlsState stuck at connecting? yes DTLS handshake fault Certificate or fingerprint mismatch no ICE and DTLS both up Read the inbound-rtp graphs instead: flat bytesReceived is a media fault.
Top-down triage order: signalling, then ICE path, then handshake, then media.

Timing resolution and what falls between samples

The roughly one-second cadence sets a hard floor on what the graphs can prove. Trickle ICE typically pulls time-to-first-frame down by 200–800 ms, stretching to 2–4 s against bulk gathering that waits on a slow TURN allocation — but the lower half of that range is entirely invisible on a curve whose points are a second apart. Measure it from updateLog timestamps or from your own console markers, never by eyeballing where a graph begins; the comparison itself is worked through in ICE Candidate Trickle vs Bulk Gathering.

Three signatures survive the coarse sampling, because they are either large or periodic:

The second PeerConnection block

The most expensive misreading of a dump has nothing to do with any individual stat. An application that tears down and rebuilds its RTCPeerConnection after a failure produces a second block with a new id, and the first block’s graphs freeze at the instant of close. Chrome lists blocks in creation order, so the one at the top is the oldest — an engineer reading it concludes the call “died at 14 seconds” when what actually happened is that the client retried and the live session is three blocks further down. Diagnosis takes one glance: count the blocks carrying your page URL. More than one means a client-side rebuild, and the correct reading is the last block for current state plus the penultimate block’s final event-log lines for the reason the rebuild fired. Fix the ambiguity at the source by logging a session id on every construction and echoing it into the SDP session name, so each block is identifiable without counting.

Common Implementation Mistakes

FAQ

The dump is huge — can I trim it before sharing? The exported JSON is plain text keyed by connection and stat type. Keep the connection block that failed and delete the others; the importer (drag the JSON back onto a fresh chrome://webrtc-internals page) reads partial files fine.

Why do the graphs stop updating mid-call? Either the peer connection was closed (the event log shows close) or the tab lost focus and Chrome throttled timers. Confirm against your app’s own 1-second getStats log, which keeps running.

bytesReceived keeps climbing but the video is frozen — is the graph lying? No, it is counting more than you think. Retransmissions, FEC and bandwidth-probing padding all land on the transport and inflate bytesReceived while zero decodable frames arrive. Read framesDecoded and freezeCount on the inbound-rtp group instead: a rising byte counter beside a static framesDecoded means the decoder is waiting for a keyframe it never received, which is a request-and-retransmit problem rather than a bandwidth one.

Can I capture a dump from Android or from a WebView? Android Chrome exposes the same page and Create Dump writes into the device’s download folder, so a real Chrome tab is fully covered. An in-app WebView is not: it has no internals page of its own, and the only route is attaching the desktop remote inspector to the debuggable WebView and running your own getStats polling inside the page. Plan for that gap before shipping a hybrid app rather than discovering it during an incident.

Related: this deep-dive sits under Cross-Browser WebRTC Debugging; compare it with diagnosing ICE failures with Firefox about:webrtc and debugging SDP m-line mismatches.