Cross-Browser WebRTC Debugging

A session that connects flawlessly in Chrome can stall in Safari and fail outright in Firefox, and the reason is rarely your application code — it is the way each engine names statistics, defaults its codecs, and exposes its internal ICE and DTLS state. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it shows how to capture comparable diagnostics from Chrome, Firefox, and Safari, normalise their differences, and build a reproducible harness that surfaces the same failure in every engine.

The goal is a single observability pipeline that you can point at any browser and get a consistent answer to three questions: did ICE nominate a candidate pair, did DTLS complete, and is RTP actually flowing. Each engine answers those questions through a different tool — chrome://webrtc-internals, Firefox’s about:webrtc, and Safari’s Web Inspector — but all three ultimately expose the same RTCStatsReport graph through getStats(). Treat the built-in dashboards as fast triage and getStats() as the source of truth.

The order of investigation matters as much as the tooling. Work down the stack: confirm signalling delivered the offer and answer, then that ICE nominated a pair, then that DTLS handshook, and only then that media decoded. Skipping straight to “no video” and inspecting codecs wastes time when the real fault is two layers below. The dashboards exist to let you skip levels you have already cleared — once you have seen a nominated succeeded pair and dtlsState: connected, you can confidently spend the rest of the session on codecs and bitrate.

Cross-browser WebRTC diagnostic tooling and the shared getStats pipeline Three browser panels — Chrome webrtc-internals, Firefox about:webrtc, Safari Web Inspector — each normalised into a common getStats RTCStatsReport pipeline producing candidate-pair, transport, and RTP reports. Chrome webrtc-internals live stat graphs Firefox about:webrtc ICE stats table Safari Web Inspector getStats() only getStats() — RTCStatsReport normalised stat ids candidate-pair transport / DTLS inbound/outbound-rtp
Each engine exposes a different dashboard, but all normalise to the same getStats report graph.

Step 1 — Capture a Chrome webrtc-internals dump

Open chrome://webrtc-internals in a second tab before you start the call — the page only records peer connections created after it loads. Each RTCPeerConnection appears as a collapsible block keyed by its URL and a numeric id. The top of the block lists every API call (createOffer, setLocalDescription, addIceCandidate) with timestamps, and below it sit the live getStats timeline graphs.

The single most useful action is the Create Dump button at the top of the page: it serialises the full event log plus every stat sample into a JSON file you can attach to a bug report. For the mechanics of reading those timeline graphs and tracing nomination timing, see the deep-dive on reading chrome://webrtc-internals dumps.

// Capture a getStats snapshot alongside the webrtc-internals dump so logs line up.
// Run this on a 1 s interval — the same cadence webrtc-internals samples at.
async function snapshotStats(pc, label) {
  const report = await pc.getStats();             // RTCStatsReport (a Map)
  const pair = [...report.values()].find(
    s => s.type === 'candidate-pair' && s.nominated // the active path
  );
  console.log(label, {
    ts: Date.now(),
    pairState: pair?.state,                        // 'succeeded' once nominated
    rtt: pair?.currentRoundTripTime,               // seconds; multiply by 1000 for ms
    bytesSent: pair?.bytesSent
  });
}
setInterval(() => snapshotStats(peerConnection, 'chrome'), 1000);

When a connection fails to reach connected, the dump’s event log tells you exactly which step stalled — a missing setRemoteDescription points at signalling, while a candidate-pair that never leaves in-progress points at NAT traversal. Correlate that against your ICE Candidate Gathering & Filtering policy: an over-aggressive filter that drops srflx candidates shows up here as a pair set that never includes a public address.

Read the graphs in a fixed order so triage is mechanical rather than exploratory. Start with the candidate-pair group and confirm a pair reaches state: succeeded; if none does, stop — the fault is below the media layer and chasing codecs or bitrate is wasted effort. Only once a pair is nominated should you look at the outbound-rtp/inbound-rtp groups for bytesSent, framesEncoded, and framesDecoded. The bweForVideo graph (Chrome’s bandwidth-estimate trace) is the one to watch when the call connects but quality is poor: a sawtooth that keeps collapsing to a low ceiling indicates congestion rather than a connectivity defect, and it is the same signal the bandwidth estimation and congestion control reference interprets from remote-inbound-rtp.

Why the event log localises faults the graphs cannot

The graphs and the API event log answer different questions, and the gap between them is a sampling artefact. webrtc-internals builds its timelines from getStats() samples taken once per second, and Chrome serves a cached stats report for roughly 50 ms behind that, so nothing shorter than a poll interval ever reaches a graph. The event log is written synchronously at call time with sub-millisecond timestamps. A setRemoteDescription that throws and is retried 300 ms later, an addIceCandidate rejected because no remote description had been applied yet, or a transceiver added and immediately stopped are all invisible in the graphs and unmistakable in the log. Read the log top to bottom first, then open the graphs only to quantify what the log already told you.

Two Chrome behaviours change how a modern dump should be read. The legacy callback-based getStats() was removed in Chrome 117, so dumps from current builds contain spec-shaped reports only — triage notes that still reference googRtt, googFrameRateReceived, or the ssrc-typed legacy reports describe a format Chrome no longer emits, and the replacements are candidate-pair.currentRoundTripTime and inbound-rtp.framesPerSecond. Second, the dump is a single JSON object whose stat keys encode <reportId>-<attributeName>, each holding a start timestamp plus a comma-separated value series. That layout is what makes dumps diffable: two runs of the same scenario produce the same key set, so a key present in one run and absent in the other is itself the finding — a missing outbound-rtp series means the encoder never produced a frame, not that the sampler dropped data.

chrome://webrtc-logs is the companion page most engineers never open. It lists the text and event logs Chrome retained from applications that requested capture, and it survives the call tab closing, which is Chrome’s only answer to Firefox’s post-mortem retention. On a machine you control, launching Chrome with --enable-logging --v=1 writes the native WebRTC log to chrome_debug.log, containing the STUN transaction ids and DTLS handshake lines that never surface through getStats() at all. Reserve that level for the case where a dump shows a pair stuck at in-progress and you need to know whether checks were sent and unanswered or never sent at all.

Step 2 — Read Firefox about:webrtc

Firefox exposes the same underlying state through about:webrtc, but the layout is a flat HTML report rather than live graphs. Each connection lists its ICE stats as a table of candidate pairs with their local/remote candidate, priority, nominated flag, and selected status. Unlike Chrome, Firefox keeps the report after the connection closes, which makes it ideal for post-mortem analysis of a session that already dropped.

Use the Save Page control at the top to persist the full report — it captures the SDP for both directions, the ICE candidate list, and the RTP/RTCP stat history in one HTML file. The candidate-pair table is the fastest way to spot an ICE failure: if no row carries nominated: true, connectivity checks never succeeded. The dedicated walkthrough on diagnosing ICE failures with Firefox about:webrtc covers reading the nominated path and saving the log in detail.

// Firefox names some stats differently — normalise before comparing across engines.
// Example: Firefox historically reported `mozRtt`; modern builds use currentRoundTripTime.
function normalisePair(stat) {
  return {
    state: stat.state,
    nominated: stat.nominated ?? stat.selected,         // Firefox exposed `selected`
    rtt: stat.currentRoundTripTime ?? stat.mozRtt,      // legacy fallback
    bytesSent: stat.bytesSent
  };
}

A practical Firefox quirk: it enables IPv6 and mDNS host candidates aggressively, so a dual-stack mismatch that Chrome papers over can surface here as a candidate pair that gathers but never nominates. That is a useful signal rather than a Firefox bug — it means your network path is asymmetric.

The about:webrtc ICE log section below the table is the second thing to read. It lists each connectivity-check transition with a timestamp, so you can see whether a pair moved Waiting → In Progress → Succeeded or stalled. A pair that reaches In Progress and then disappears usually lost a STUN binding — on mobile and carrier-grade NAT those mappings can refresh in under 30 seconds, expiring a candidate before the remote peer applies it. When you see that pattern, the fix lives in your gathering and trickle strategy rather than in the browser: forwarding candidates incrementally as covered in ICE Candidate Trickle vs Bulk Gathering keeps bindings fresh enough to nominate. Read the transitions as a state machine, and read the connection-level state Firefox prints alongside them with the same care — a pair dropping back to checking is recoverable, whereas a terminal transition is not, a distinction worked through in Disconnected vs Failed ICE States.

Candidate-pair check state machine in the about:webrtc ICE log A pair moves Frozen to Waiting to In Progress to Succeeded, branches to Failed after exhausted retries, and loops back to Waiting when a STUN binding expires before the remote peer applies the candidate. about:webrtc ICE log — candidate-pair transitions STUN binding expires < 30 s Frozen gathered, idle Waiting queued by priority In Progress check sent Succeeded nominated: true Failed pair discarded binding response retries exhausted A pair that reaches In Progress and vanishes lost its mapping — it never reached Failed.
The about:webrtc ICE log prints these transitions per pair; a disappearing In Progress row means an expired binding, not a rejected check.

Raising Firefox’s log level past what the table shows

The about:webrtc tables report outcomes; they do not report the STUN transactions that produced those outcomes. When a pair silently fails to nominate you need the level below, and Firefox exposes it through about:logging or the MOZ_LOG environment variable on a command-line launch. The modules worth enabling are signaling for JSEP and offer/answer decisions and mtransport for ICE, DTLS, and STUN detail; at level 5 — timestamp,signaling:5,mtransport:5 — Firefox prints every binding request with its transaction id and every response or timeout against it, which is exactly what separates “no response arrived” from “we never sent a check for that pair”. Point MOZ_LOG_FILE at a path so the log survives the browser restart your reproduction probably needs, because the in-memory buffer is truncated aggressively on a busy connection.

Firefox also exposes deterministic path shaping through preferences, which makes it the cheapest engine on which to build a failure matrix. media.peerconnection.ice.relay_only forces every candidate through TURN, reproducing a locked-down network without touching the OS firewall; media.peerconnection.ice.no_host strips host candidates so only reflexive and relay paths are exercised; and media.peerconnection.ice.obfuscate_host_addresses controls the mDNS .local replacement that otherwise renders host candidates unreadable in a log. Flipping the first is the fastest way to test a suspicion that a corporate proxy is the real fault — if relay-only connects where the default policy fails, the problem is candidate reachability rather than signalling, and the port and transport choices that survive such networks are set out in Forcing TURN over TCP 443 on Locked-Down Networks.

Step 3 — Achieve getStats() parity in Safari

Safari ships no dedicated WebRTC dashboard. Its Web Inspector (Develop menu → Show Web Inspector) gives you the console and network panels, so all diagnostics flow through programmatic getStats(). This makes Safari the engine that forces you to build a portable stats pipeline — and once that pipeline works in Safari it works everywhere. The embedded case is harsher still, because a WKWebView has no inspector attached by default; the setup and the permission traps specific to it are collected in Debugging WebRTC on Safari and iOS WKWebView.

The key parity problem is naming and presence. Safari (WebKit) historically lagged on stat fields such as currentRoundTripTime on candidate-pair, and it computes some values only on transport or remote-inbound-rtp reports. Build a single extraction layer that looks across report types rather than assuming a field lives on one.

getStats field availability by engine A matrix listing five getStats fields against Chrome, Firefox, and Safari, showing which engine exposes each field directly, under a legacy alias, or only on another report type. Where each field actually lives, per engine stat field Chrome Firefox Safari candidate-pair.nominated present selected (pre-117) present candidate-pair.currentRoundTripTime present present often absent remote-inbound-rtp.roundTripTime present present primary source transport.dtlsState present present present inbound-rtp.framesDecoded present present present
Presence, not correctness, is the parity problem — an absent field is a report-type difference, never a dead network.
// Portable extractor: works in Chrome, Firefox, and Safari by searching all report types.
async function readTransportHealth(pc) {
  const report = await pc.getStats();
  const stats = [...report.values()];

  const pair = stats.find(s => s.type === 'candidate-pair' &&
    (s.nominated || s.selected || s.state === 'succeeded'));
  const transport = stats.find(s => s.type === 'transport');     // DTLS state lives here
  const inbound = stats.find(s => s.type === 'inbound-rtp' && s.kind === 'video');

  return {
    dtls: transport?.dtlsState,                                  // 'connected' when handshake done
    selectedPairRtt: pair?.currentRoundTripTime ?? null,
    framesDecoded: inbound?.framesDecoded ?? 0,                  // 0 means no media despite ICE
    packetsLost: inbound?.packetsLost ?? 0
  };
}

If dtlsState is connected but framesDecoded stays at zero, the transport is healthy and the fault is in codec negotiation — a frequent Safari outcome because its codec defaults differ from Chrome’s. Cross-reference your media path against interpreting getStats() for congestion signals, which uses the same inbound-rtp and remote-inbound-rtp reports to read loss and bitrate. The opposite reading — dtlsState stuck at connecting while the candidate pair sits at succeeded — is a handshake problem rather than a media one, and the certificate, fingerprint, and MTU causes behind it are enumerated in Debugging DTLS Handshake Failures.

Making Safari’s sampler trustworthy

Safari’s missing dashboard is the visible problem; its scheduler is the one that quietly corrupts traces. WebKit clamps timers hard in hidden tabs, and Chrome throttles a backgrounded page to roughly one timer wake per minute after about five minutes out of view. A harness that polls with setInterval and then loses foreground — because the driver opened a second tab, or an engineer switched windows — produces a trace with a 60-second hole that reads exactly like a frozen connection. Diagnose it by comparing stat.timestamp deltas against your own capture time: if both jump together the sampler stalled, whereas a stalled connection shows regular samples with unchanging counters. The fix is to drive the poll from the test runner rather than from page script, or to gate the interval on document.visibilityState and write an explicit marker so the trace is honest about the gap.

// Guard the sampler against timer throttling so a hidden tab is visible in the trace.
function sampleWithVisibilityGuard(probe) {
  if (document.visibilityState !== 'visible') {
    probe.trace.push({ t: Date.now(), gap: 'hidden' }); // mark, never silently skip
    return;
  }
  const drift = Date.now() - (probe.lastTick ?? Date.now());
  probe.lastTick = Date.now();
  if (drift > 3000) {                                   // 1 s cadence slipped past 3 s
    probe.trace.push({ t: Date.now(), gap: 'throttled', drift });
  }
}

Two further WebKit traps are worth pre-empting. Calling getStats() on a peer connection that has already reached closed rejects with InvalidStateError in Safari where Chrome resolves with an empty report, so the final sample of any teardown test needs a try/catch or the harness records a failure that is really a race with cleanup. And Safari’s autoplay policy means a receiver can be decoding correctly — framesDecoded climbing at a steady rate — while the <video> element never paints because playback was never started by a gesture. Assert on the stat, never on the rendered pixels, when deciding whether media arrived.

Step 4 — Build a reproducible cross-browser test harness

Manual dashboard inspection does not scale across three engines and many network conditions. Wrap the portable extractor in a harness that drives the same negotiation in each browser, records a timestamped stat trace, and writes a single normalised log you can diff. Run it under Playwright or your WebDriver of choice so Chrome, Firefox, and Safari (via safaridriver) execute the identical script.

// Harness core: poll normalised stats at a fixed cadence and emit a JSON trace per engine.
class CrossBrowserProbe {
  constructor(pc, engine) {
    this.pc = pc;
    this.engine = engine;          // 'chrome' | 'firefox' | 'safari'
    this.trace = [];
  }

  start(intervalMs = 1000) {       // 1 s matches webrtc-internals sampling
    this.timer = setInterval(async () => {
      const health = await readTransportHealth(this.pc);
      this.trace.push({ t: Date.now(), engine: this.engine, ...health });
    }, intervalMs);
  }

  stop() {
    clearInterval(this.timer);
    // Emit a deterministic trace the CI job can diff across engines.
    return JSON.stringify(this.trace, null, 2);
  }
}

Verification means asserting the same three milestones in every engine’s trace: a candidate-pair reaching succeeded, dtlsState reaching connected, and framesDecoded climbing above zero within your fallback budget of 3–5 seconds. If one engine misses a milestone the others hit, you have isolated an engine-specific defect rather than an application bug — exactly the outcome a portable harness exists to produce. For signalling-layer divergence such as glare or m-line ordering, pair this harness with debugging SDP m-line mismatches.

Milestone timeline asserted per engine A five-second timeline marking offer and answer applied, candidate pair succeeded, DTLS connected, and first frame decoded, with the three-to-five-second fallback budget shaded at the right. Milestones every engine trace must hit fallback budget 3–5 s answer applied signalling: stable pair succeeded candidate-pair dtlsState connected transport framesDecoded > 0 inbound-rtp 0 s 1 s 2 s 3 s 4 s 5 s A milestone missing past the budget in one engine only is an engine defect, not an app bug.
Assert the same three milestones on every engine trace; the timing gap between them localises the fault.

Run the harness across a small matrix of network conditions, not just the happy path, because most cross-browser failures only appear under constraint. A useful minimum matrix is: open network, iceTransportPolicy: 'relay' (forces TURN so you exercise the relay path every engine treats slightly differently), and a simulated Wi-Fi-to-cellular handoff that triggers an ICE restart — the interface-change sequence that cell reproduces is walked through in Handling Wi-Fi to Cellular Network Handover. Capture one normalised trace per cell and store them as CI artifacts keyed by engine × condition. When a regression lands, diffing the new trace against the stored baseline for the same cell tells you in seconds whether the milestone timing shifted — for instance, DTLS taking longer to reach connected after a dependency bump. Because the harness emits deterministic JSON rather than engine-specific dumps, the diff is meaningful even though Chrome, Firefox, and Safari produced the data through three entirely different tools.

Choosing the numbers each milestone is asserted against

A milestone assertion is only useful when its budget matches the transport under test. With trickle enabled, a candidate pair on an open network should reach succeeded 200–800 ms after the answer is applied; forcing bulk gathering pushes the same milestone out to 2–4 s, so a harness asserting one flat 1-second budget fails the bulk cell for reasons that have nothing to do with the browser. The relay cell needs its own baseline as well: a TURN relay adds 20–40 ms one-way, so pair RTT there is legitimately 40–80 ms higher than on the direct path and must only be compared against relay history. Single-region STUN inflates connect latency by 40–60% for distant clients relative to a multi-region deployment — again a baseline difference to encode per cell, not a regression to alert on.

Set thresholds relative to the stored baseline rather than to an absolute constant. Failing the job when a milestone slips more than 50% against the same engine × condition cell, and warning above 20%, catches a DTLS handshake drifting from 120 ms to 400 ms after a dependency bump while tolerating the ordinary jitter of a shared CI runner. Signalling delivery over a WebSocket stays under 10 ms when the signalling server runs beside the test, so an offer-to-answer gap in the hundreds of milliseconds indicts the test infrastructure before the browser. Cap the harness at 3 ICE restart attempts, matching what production should do, so a genuinely unreachable cell fails in seconds instead of retrying until the job times out.

Edge Cases & Browser Quirks

Common Implementation Mistakes

FAQ

Why does the same getStats() field appear in Chrome but not Safari? WebKit implements the stats spec on its own timeline and computes some values lazily on transport or remote-inbound-rtp rather than candidate-pair. Read with ?? fallbacks across report types instead of asserting a single field, and your extractor will behave consistently in all three engines.

Can I record a webrtc-internals-style trace in Safari? Not from a built-in dashboard — Safari has none. Drive getStats() on a 1-second interval through the Web Inspector console or a test harness, serialise the samples to JSON, and you reproduce the same timeline data the Chrome dump contains.

My call connects in Chrome but shows no video in Safari. Where do I look first? Check transport.dtlsState and inbound-rtp.framesDecoded. If DTLS is connected but frames stay at zero, the transport is fine and the problem is codec negotiation — Safari’s H.264-leaning defaults differ from Chrome’s VP8, so inspect the negotiated m= lines.

How do I keep traces comparable across browsers in CI? Normalise every engine’s output to one schema with a portable extractor, drive the identical negotiation under Playwright/WebDriver, and assert the same three milestones (pair succeeded, DTLS connected, frames decoded) per engine. Diff the normalised JSON, never the raw dumps.

Why is remote-inbound-rtp missing for the first few seconds of every call? That report is derived from RTCP receiver reports arriving back from the far end, so it cannot exist before the first one lands — typically 1–5 s in, and later on a lossy path. Its absence early in a trace says nothing about loss or RTT; treat any assertion on it as valid only after the first sample that contains it, and read RTT from candidate-pair until then.

Should I poll getStats() faster than once per second to catch short glitches? No. Chrome answers from a cached report for tens of milliseconds, so sub-second polling returns duplicate values while adding main-thread work that itself perturbs the measurement. A 1 s cadence matches what webrtc-internals graphs, and anything shorter-lived than that — a rejected addIceCandidate, a momentary signalling-state flip — belongs in the event log or MOZ_LOG output, not in a stats trace.

Related: start from the WebRTC Protocol Stack & Signaling Servers guide, then pair this with reading chrome://webrtc-internals dumps, diagnosing ICE failures with Firefox about:webrtc, and debugging SDP m-line mismatches.