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.
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.
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
- Reproduce the failing call, then open
about:webrtcin a new tab. Each connection appears as a section headed by its SDP and timestamps. - Scroll to the ICE Stats table. Each row is a candidate pair: local candidate, remote candidate, priority,
nominated, andselected. The columns are sortable — sort bynominatedto surface the winning pair instantly. - Read the nominated path. Exactly one row should show
nominated: trueandstate: 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 infailed— a terminal state worth distinguishing from a recoverable blip, as covered in Disconnected vs Failed ICE States. - Inspect candidate types on the nominated row. A
relay/relaypair 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 gatheredsrflxcandidates but never nominated points at a symmetric-NAT or dual-stack mismatch. - Click Save Page at the top of
about:webrtcto 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.
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
- Looking for live graphs.
about:webrtcis a table-and-log report, not an animated dashboard — read state transitions from the ICE log, not a moving line. - Assuming
nominatedis always present. Pre-117 Firefox surfacedselectedinstead; normalise with??or you will conclude no pair won when one did. - Misreading mDNS host candidates. Firefox replaces local IPs with
.localnames by default; an unresolved host candidate on the table is expected privacy behaviour, not the failure cause. - Ignoring IPv6 pairs. Firefox gathers IPv6 aggressively; a dual-stack mismatch shows as a gathered-but-never-nominated pair, which is the diagnosis, not noise.
- Not saving the page. The report persists only until the tab navigates; click Save Page before you close it so the post-mortem survives.
- Treating priority as a prediction. The priority column controls the order checks are scheduled, not which pair wins. A high-priority host pair that fails and a low-priority relay pair that succeeds is the normal, correct outcome on a restrictive network.
- Reading the wrong connection section. A page that created and discarded several peer connections lists them all; match the section by SDP session id or timestamp rather than by position, or you will diagnose a connection that was torn down deliberately.
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.