Diagnosing ICE Failures with Firefox about:webrtc

When a connection negotiates cleanly but never reaches connected, Firefox’s about:webrtc report tells you precisely where ICE gave up. This guide is part of the Cross-Browser WebRTC Debugging guide, and it shows how to read the ICE stats table, interpret the candidate-pair rows, identify the nominated path, and save the log for post-mortem analysis — Firefox’s key advantage being that the report survives after the connection drops.

Context & Trade-offs

Unlike Chrome’s live graphs, about:webrtc is a flat HTML report you can open at any time, including after a failed call — Firefox retains the data until the tab navigates away. That makes it the right tool for post-mortems: you can reproduce a failure, then calmly read the candidate-pair table without racing a disappearing dashboard. The same post-mortem workflow on Apple’s engine is a different exercise entirely, covered in Debugging WebRTC on Safari and iOS WKWebView, because Safari exposes no equivalent internal report.

The trade-off is resolution. The report is a snapshot-plus-history rather than a continuously animated timeline, so you read state transitions from the ICE log section rather than watching a graph move. Firefox also enables IPv6 and mDNS host candidates aggressively, which means a dual-stack path mismatch surfaces here as a candidate pair that gathers but never nominates — a useful diagnostic signal rather than a Firefox defect, and one worth resolving against IPv6 Dual-Stack ICE Handling before you go hunting for a NAT problem that isn’t there.

That persistence is a consequence of where the data lives, not a UI choice. Chrome’s dashboard subscribes to a live event stream held in the memory of the tab displaying it, so closing that tab destroys the evidence. Firefox renders about:webrtc from a parent-process registry that every RTCPeerConnection in the browser reports into, so the record outlives the page that created it — close the call tab entirely and its section is still listed.

The registry is not unbounded, though, and that is the limit engineers hit. The per-connection ICE log is a ring buffer, so a long session that renegotiates several times pushes the original gathering lines out of it: the candidate-pair table stays correct because it is regenerated from live stats, but the log explaining how the table reached that state is gone, leaving a clearly failed connection with no visible check history. On anything longer than a few minutes, open the report before you reproduce and hit Save Page the moment the call breaks. Recent releases (roughly Firefox 120 onward) also render each connection as a collapsed section, so a report holding six historical connections looks empty until you expand the one whose timestamp matches your failure.

Chrome live dashboard versus Firefox static reportFive-row comparison matrix covering report persistence, live graphs, the candidate-pair table, export format, and how state transitions are read in each browser. Same failure, two evidence surfaces What each engine's built-in report gives you during a post-mortem capability chrome://webrtc-internals about:webrtc (Firefox) Report survives after the call ends no, cleared on close yes, until tab navigates Live animated bitrate graphs yes, per-stream no, tables only Candidate-pair table live, fixed order sortable columns Single-file export for a bug report JSON event dump Save Page, full HTML How you read state transitions watch the graph move scroll the ICE log Firefox wins on persistence; Chrome wins on live resolution. Reproduce in Firefox when the call already failed.
Capability matrix: what chrome://webrtc-internals and about:webrtc each give you.

Reading the ICE log lines

Beneath the tables, the ICE log is raw output from Firefox’s transport layer, and its line shape is stable enough to grep. Each line carries the connection identifier, the media component, and a pair label of the form CAND-PAIR(hash) followed by the local and remote candidate strings, so filtering on one hash reconstructs that pair’s entire life in a few lines. The transitions worth reading are frozen → waiting → in-progress → succeeded, or the same sequence terminating in failed. A pair that never leaves frozen belongs to a foundation whose first check never completed, so it was never scheduled at all and its inactivity says nothing about the network. A pair sitting in in-progress sent binding requests that went unanswered — a silent drop. A pair that reaches failed got an explicit rejection: an ICMP port-unreachable, an authentication error, or exhausted retransmits.

Stalled checks are also self-timing. Firefox retransmits each STUN binding request up to seven times by default (media.peerconnection.ice.stun_client_maximum_transmits) on an exponentially backing-off timer, so a blackholed UDP path produces several seconds of request lines with no reply before the candidate is written off. That is why an application-level fallback timeout in the 3–5 s range is worth more than waiting for the ICE agent’s own verdict, and why the username fragment printed alongside each pair matters: it identifies the ICE generation and changes on every restart.

Minimal Runnable Implementation

To make the about:webrtc table easy to correlate, log the same candidate-pair selection from your app, normalising Firefox’s historical field names so the output matches what Chrome and Safari produce.

// Normalise Firefox candidate-pair stats so logs match across engines.
// Older Firefox exposed `selected`; modern builds use `nominated` per spec.
async function logSelectedPair(pc) {
  const report = await pc.getStats();
  for (const s of report.values()) {
    if (s.type !== 'candidate-pair') continue;
    const isActive = s.nominated ?? s.selected;       // Firefox legacy fallback
    if (!isActive) continue;

    const local = report.get(s.localCandidateId);     // resolve the candidate detail
    const remote = report.get(s.remoteCandidateId);
    console.log('[ff-pair]', {
      state: s.state,                                  // 'succeeded' on the winning pair
      localType: local?.candidateType,                // host | srflx | relay
      remoteType: remote?.candidateType,
      rtt: s.currentRoundTripTime ?? s.mozRtt          // legacy field fallback
    });
  }
}
setInterval(() => logSelectedPair(peerConnection), 1000);

Those console fields map one-to-one onto columns in the ICE Stats table, so a 1 s polling log and the saved report can be read side by side. The annotated row layout below shows which cell corresponds to which stat.

Annotated ICE Stats candidate-pair rowsThe six columns of the about:webrtc ICE Stats table shown across one nominated row, one failed mDNS host row, and one in-progress relay row, with a key explaining how to read each field. Reading one candidate-pair row Every row in ICE Stats is one local/remote pair the agent tried local candidate remote candidate priority nominated selected state srflx 198.51.100.9 srflx 203.0.113.7 17563648 true true succeeded host a1b2c3.local host d4e5f6.local 8331264 false false failed relay 192.0.2.44 srflx 203.0.113.7 5219071 false false in-progress How to read the three rows above nominated true plus state succeeded marks the one pair media actually traverses a .local host row is mDNS privacy masking, not the cause of the failure a relay row stuck in-progress points at TURN reachability, not NAT shape
The six ICE Stats columns across a winning pair, an mDNS host pair, and a stalled relay pair.

Checking the prefs before blaming the network

A surprising share of “Firefox can’t connect” tickets are a profile configured for an earlier experiment and never reset. Four about:config preferences change what the ICE Stats table can possibly contain, and each removes whole classes of candidate rather than degrading them, so check them before you read the table.

# about:config — check these four before concluding the network is at fault
media.peerconnection.ice.obfuscate_host_addresses = true    # default; renders host candidates as .local
media.peerconnection.ice.relay_only = false                 # true forces every pair through TURN
media.peerconnection.ice.no_host = false                    # true suppresses host candidates entirely
media.peerconnection.ice.default_address_only = false       # true gathers one interface only

relay_only = true produces a table containing nothing but relay pairs, which reads exactly like a symmetric-NAT diagnosis and is not one. default_address_only = true is the subtler trap: on a laptop with a VPN adapter up, Firefox gathers only the default route’s address, so the host candidate that would have won on the local LAN never appears and the pair silently falls back to relay. When the prefs are clean and you still need more detail than the report’s log pane shows, start the browser with the transport modules turned up.

# Launch Firefox with a full signalling trace alongside the about:webrtc report
MOZ_LOG=signaling:5,mtransport:5,jsep:5   # level 5 is debug; level 4 roughly halves the volume
MOZ_LOG_FILE=/tmp/ff-ice.log              # one file per process, suffixed with the pid

mtransport carries the ICE and DTLS lines, jsep carries the offer/answer decisions that produced the candidates, and having both in one file lets you prove whether a missing pair was never gathered or was gathered and never checked — a distinction the report alone cannot always settle.

Reproduction Steps & Debugging Log Patterns

  1. Reproduce the failing call, then open about:webrtc in a new tab. Each connection appears as a section headed by its SDP and timestamps.
  2. Scroll to the ICE Stats table. Each row is a candidate pair: local candidate, remote candidate, priority, nominated, and selected. The columns are sortable — sort by nominated to surface the winning pair instantly.
  3. Read the nominated path. Exactly one row should show nominated: true and state: succeeded; that local/remote candidate pair is the path media flows over. If no row is nominated, connectivity checks never succeeded and the connection is in failed — a terminal state worth distinguishing from a recoverable blip, as covered in Disconnected vs Failed ICE States.
  4. Inspect candidate types on the nominated row. A relay/relay pair means both peers fell back to TURN — connectivity works but you are paying relay latency, so revisit your TURN Server Configuration & Auth. A pair that gathered srflx candidates but never nominated points at a symmetric-NAT or dual-stack mismatch.
  5. Click Save Page at the top of about:webrtc to persist the full report — SDP, candidate list, and RTP history — as a single HTML file for the bug report.

Expected log pattern for a healthy nominated path:

// [ff-pair] { state: 'succeeded', localType: 'srflx', remoteType: 'srflx', rtt: 0.041 }

A failing session instead shows every candidate-pair row with state: 'failed' or in-progress, none nominated, while iceConnectionState reports failed. When only host pairs appear and no srflx row was ever produced, your candidate gathering is the culprit — confirm against ICE Candidate Gathering & Filtering, because a STUN binding that refreshed in under 30 seconds on mobile can expire a candidate before nomination.

Decision tree from ICE Stats table to root causeStarting at the ICE Stats table, branch on whether any pair is nominated, then on whether srflx or relay candidates were gathered, or on the candidate types of the winning pair, ending in four named diagnoses. ICE Stats table in about:webrtc Any row with nominated: true? no yes Any srflx or relay candidate? Nominated pair candidate types host only srflx present relay / relay srflx or host STUN unreachable fix gathering first Checks blocked symmetric NAT, firewall TURN relay path adds 20-40 ms one way Direct path healthy RTT under 50 ms iceConnectionState: failed ICE fine, inspect DTLS Answer "did any pair win" before "which pair won" — the two questions have different fixes.
Decision tree: turning the nominated column into a named root cause.

Two branches of that tree lead out of this report. When checks are blocked by a symmetric NAT or an egress firewall, the fix is transport shape rather than more diagnosis, and Forcing TURN over TCP 443 on Locked-Down Networks covers the configuration that survives the strictest corporate egress. When a pair is nominated and succeeded but media still never plays, ICE has done its job and the fault has moved one layer down — take the saved report to Debugging DTLS Handshake Failures instead of re-reading candidate rows.

Timing the gathering phase from the log

The log timestamps turn the report into a latency measurement, which is the part most people leave on the table. Subtract the timestamp of the first srflx candidate line from the timestamp of the setLocalDescription entry above it and you have your real STUN round trip; on a well-placed server that gap is tens of milliseconds, and anything past 300 ms means the binding request crossed a continent. Do the same subtraction between the first candidate line and the line where the winning pair reaches succeeded and you have the time to first frame that trickling is supposed to compress — a trickled session should reach a nominated pair 200–800 ms after gathering starts, against 2–4 s if candidates were withheld until gathering completed.

Capture those two intervals once from a healthy call and they become a regression baseline: any later report whose gathering interval has doubled says the STUN tier moved, degraded, or started rate-limiting you, which is the single-session view of the fleet-wide measurement in Monitoring STUN Binding Success Rates. The caveat is that these timestamps are local wall-clock, so they are comparable only within one report — correlating a Firefox report against a peer’s Chrome dump needs an application-level identifier logged on both sides, not timestamp arithmetic.

Common Implementation Mistakes

Failure mode: two nominated rows in one table

After an ICE restart the table frequently shows two rows with nominated: true, which looks impossible and sends people hunting for a browser bug. It is not one: the restart creates a fresh ICE generation with a new username fragment, and the registry keeps the previous generation’s pairs alongside the new ones, so the table is the union of two check lists. The diagnosis is the ufrag — expand the local candidate strings and compare the fragment on each nominated row against the a=ice-ufrag line in the most recent local SDP at the top of the section. The row whose fragment matches is the live path; the other is history.

What you do next depends on which row is stale. If the old row still shows succeeded while the new generation’s pairs are all in-progress, the restart has not converged yet and media is still riding the previous path — expected behaviour, and the reason a well-executed restart is invisible to users, as Triggering an ICE Restart Without Dropping Media covers. If the new generation has already nominated a pair and the old row is still marked succeeded, you are reading a stale snapshot; reload the report. And if three or more generations are stacked up, your recovery logic is looping — cap restarts at three attempts and fall back to full renegotiation rather than firing a fourth.

FAQ

No candidate pair is nominated — what does that mean? Connectivity checks never produced a working path, so ICE is in failed. Check whether any srflx or relay candidates were gathered at all; if only host candidates exist, NAT traversal could not start and the fault is in gathering or your STUN/TURN reachability.

Can I read about:webrtc after the call already ended? Yes — that is its main advantage over Chrome. Firefox keeps the report until you navigate the tab, so you can reproduce a failure and analyse the table afterward at your own pace.

Every host candidate shows a .local name — should I disable mDNS to debug? No — turning off media.peerconnection.ice.obfuscate_host_addresses changes the failure you are debugging, because a real host candidate matches a remote peer’s checks differently than an unresolvable .local one. Diagnose with the default on: a peer on the same LAN resolves the name and the host pair reaches succeeded, while a peer that cannot resolve it fails that pair and ICE proceeds to the reflexive pairs exactly as designed. Flip the pref only to confirm a hypothesis, then flip it back before you measure anything.

Why does the nominated pair change mid-call without an ICE restart? Firefox continues sending consent checks on the nominated pair every few seconds, and it keeps other succeeded pairs alive as backups. When the nominated path stops answering — a Wi-Fi handoff, a NAT binding expiring on a mobile network where refreshes need to stay under 30 s — the agent can promote a surviving pair without any signaling at all. In the table this looks like a second row acquiring nominated: true with a different candidate type, and it is a healthy recovery, not a defect. It matters mainly because your RTT will step up if the promoted pair is a relay, which typically adds 20–40 ms one way compared with the direct path it replaced.

Related: this deep-dive sits under Cross-Browser WebRTC Debugging; compare it with reading chrome://webrtc-internals dumps and the trickle strategy in ICE Candidate Trickle vs Bulk Gathering.