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.
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.
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.
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 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.
// 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.
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.
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. 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.
Edge Cases & Browser Quirks
- getStats() naming differences. Chrome and the current spec expose
nominatedoncandidate-pair; older Firefox builds (pre-117) surfacedselected, and WebKit historically omittedcurrentRoundTripTimeon the pair, computing RTT only onremote-inbound-rtp. Always read with??fallbacks rather than asserting a single field name. - Transport vs candidate-pair RTT. Chrome reports RTT on both
candidate-pairandremote-inbound-rtp; Safari often populates only the latter. Comparing the wrong report makes Safari look like it has zero RTT. - Codec defaults. Chrome defaults to VP8 for video and negotiates VP9/AV1 when offered; Safari historically preferred H.264 (hardware-backed) and only added VP8/VP9 later, so an unmunged offer can negotiate different codecs per engine. A connection that shows
dtlsState: connectedbut no decoded frames in Safari is almost always a codec mismatch, not a transport failure. - mDNS host candidates. Chrome and Firefox replace local IPs with
.localmDNS names by default. In a dump this looks like a host candidate with no routable address — expected behaviour, not a leak or a bug. Safari’s mDNS handling differs and can leave host candidates unresolved on some LANs. - Stat retention. Firefox’s
about:webrtcsurvives connection teardown; Chrome’swebrtc-internalsclears live graphs when the page closes. Capture the Chrome dump before closing the tab.
Common Implementation Mistakes
- Asserting a single stat field name. Hardcoding
stat.currentRoundTripTimeand treating its absence as a network failure produces false positives on Safari. Normalise first. - Reading stats from the wrong report type. Looking for DTLS state on
candidate-pairinstead oftransport, or media counters ontransportinstead ofinbound-rtp, yields empty results and misdiagnosed failures. - Opening webrtc-internals too late. The page does not retroactively attach to existing peer connections; open it before the call or you get an empty dump.
- Comparing raw dumps across engines. Chrome JSON, Firefox HTML, and Safari console output are not diffable by hand. Normalise to one schema before comparing.
- Ignoring codec defaults when triage shows healthy transport. Engineers chase ICE for hours when
dtlsState: connectedplus zeroframesDecodedalready points squarely at codec negotiation.
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.
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.