ICE Candidate Gathering & Filtering: Architecture, Configuration & Debugging
Real-time connectivity hinges on deterministic ICE candidate generation, strict filtering, and tightly synchronised exchange. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it provides a step-by-step implementation path for production deployments: how the ICE agent discovers candidates, how to filter and prioritise them, how to transmit them over your signalling channel, and how to verify the result under real NAT topologies. The goal is a connection that reaches iceConnectionState === 'connected' quickly and predictably, with explicit handling for browser constraints and network fallbacks rather than the hopeful defaults most demos ship.
ICE (Interactive Connectivity Establishment, RFC 8445) sits between session negotiation and media transport. It enumerates every plausible network path between two peers, probes them in priority order, and nominates the best working pair. Get the gathering and filtering policy right and you collapse Time-to-First-Frame; get it wrong and you ship a product that “works on my laptop” but fails on cellular, in enterprises, and behind carrier-grade NAT.
Candidate Types & Discovery Flow
ICE produces four candidate types, each representing a different vantage point on the network. Host candidates come from local interfaces. Server-reflexive (srflx) candidates expose your public NAT mapping via STUN. Peer-reflexive (prflx) candidates are discovered mid-connectivity-check when a packet arrives from an address neither side advertised. Relay candidates are allocated on a TURN server and used when no direct path exists.
The priority each candidate receives follows the RFC 8445 formula priority = (2^24 × type_pref) + (2^8 × local_pref) + (256 − component_id). Host candidates carry the highest type preference, then srflx, then relay — so direct paths are always tried before falling back to a relay you pay for.
The distinction matters operationally because each type has a different cost, reliability, and privacy profile. Host candidates are free and instant but only work when both peers share a routable network or sit behind the same NAT. Server-reflexive candidates cost a single STUN round trip and work for the large majority of home and small-office NATs, but fail against symmetric NAT. Peer-reflexive candidates cannot be gathered ahead of time — they only materialise during connectivity checks when a STUN binding request arrives from a transport address neither peer advertised, which commonly happens when a NAT remaps a port — so you never configure them, you only observe them in getStats(). Relay candidates always work but route every packet through infrastructure you operate and pay for, adding latency. A correct deployment gathers all four and lets ICE nominate the cheapest pair that survives connectivity checks.
| Type | Source | Latency added | Survives symmetric NAT |
|---|---|---|---|
| host | local interface | none | only if same network |
| srflx | STUN binding | one RTT to STUN | no |
| prflx | discovered mid-check | none | sometimes |
| relay | TURN allocation | 20–40 ms one-way | yes |
How Priority, Foundation and Freezing Interact
The priority formula is easy to quote and easy to misread. RFC 8445 recommends type preferences of 126 for host, 110 for peer-reflexive, 100 for server-reflexive and 0 for relay, which is why a relay candidate’s priority is roughly two orders of magnitude below a host candidate’s — the 2^24 × type_pref term dominates everything else. The local_pref term (0–65535) is where a multi-homed machine breaks ties between its own interfaces: an ICE agent with Ethernet, Wi-Fi and a VPN adapter assigns each a different local preference, and that single 16-bit field decides whether your media leaves via the corporate tunnel or the physical NIC. Chromium derives local preference partly from interface type and address family, which is why an IPv6 host candidate and an IPv4 host candidate on the same laptop are never checked simultaneously even though both are typ host.
Priority is not a latency ranking. It is a cost and likelihood ranking baked in at spec-writing time, and it happily prefers a 40 ms LAN-adjacent path over a 6 ms path that happens to be relayed. If your measured RTT on the nominated pair is worse than a pair ICE rejected, priority did its job and your topology is unusual — the fix is local_pref shaping or a relay-only policy, never disabling ICE ordering.
The foundation field, the opaque first token on the candidate line, is what makes the check list tractable. Two candidates share a foundation when they have the same type, base address, STUN/TURN server and transport. Candidate pairs whose foundations match are grouped, and only one pair per foundation starts in the waiting state; the rest start frozen. When the unfrozen pair succeeds or fails, its foundation-mates thaw. That is the mechanism that stops a peer with eight candidates on each side from firing 64 simultaneous STUN checks and self-inflicting packet loss on a thin uplink. It also explains a confusing observation in stats dumps: pairs sitting in frozen for seconds are not broken, they are queued behind a foundation-mate that has not resolved yet.
Step 1 — Map Candidate Discovery Phases
The ICE agent systematically probes local interfaces and external servers to build a connectivity matrix. The phases run concurrently once setLocalDescription() resolves.
- Host candidates: Enumerate local interfaces (Wi-Fi, Ethernet, loopback). Disable loopback in production to prevent local-only routing and IP leakage.
- Server-reflexive (srflx): Issue STUN binding requests to discover the public IP:port your NAT assigned. This is the cheapest path that survives most home and small-office NATs. Provisioning is covered in STUN Server Deployment Strategies.
- Relay (relay): Allocate TURN sessions for symmetric NAT traversal or when UDP is wholly blocked. Configuration and credential handling live in TURN Server Configuration & Auth.
- mDNS handling: Modern browsers (Chrome, Safari) obfuscate local IPs with
.localmDNS hostnames for privacy. Accept them as-is; do not attempt to resolve or strip them client-side.
// Pre-warm the candidate pool so gathering overlaps SDP creation
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, // srflx discovery
{ urls: 'turn:turn.example.com:3478', username: 'u', credential: 'p' } // relay
],
iceCandidatePoolSize: 4, // gather host+srflx eagerly before offer is created
bundlePolicy: 'max-bundle', // multiplex all m-lines onto one component
rtcpMuxPolicy: 'require' // mandatory in modern browsers
});
What iceCandidatePoolSize Actually Costs
iceCandidatePoolSize is frequently described as “pre-warming”, which undersells what it does and hides the bill. Setting it to n makes the browser instantiate n pre-allocated ICE component pools the moment the RTCPeerConnection is constructed — before any offer exists. Each pool opens a real UDP socket, issues real STUN binding requests, and, if a TURN server is configured, performs a real TURN Allocate transaction. The payoff is that by the time your application calls createOffer() and setLocalDescription(), host and srflx candidates are already in hand, so the first candidate reaches the signalling channel essentially instantly instead of after a STUN round trip. On a connection where the STUN server is 60 ms away, that reclaims 120–150 ms of Time-to-First-Frame for free.
The cost lands on your TURN infrastructure. A pool size of 4 with two TURN URLs (UDP and TLS) can hold eight allocations open per peer connection, each consuming one port from the 49152–65535 relay range and one entry in the server’s allocation table, for the entire lifetime of the object — including peer connections the user abandons by closing the tab before dialling. At a few thousand concurrent users this is the difference between a comfortable relay and one that starts refusing allocations with a 486 response. Two guardrails keep it sane: cap the pool at 2–4, and construct the RTCPeerConnection at dial time rather than at page load, so pre-gathering overlaps user intent instead of idle browsing.
Support is uneven. Chromium honours the setting; Firefox parses it and does not act on it, so Firefox clients get no pre-gathering benefit and you must not build latency budgets that assume it. Because the pool is bound to the configuration present at construction time, calling setConfiguration() afterwards to swap in fresh TURN credentials discards the pre-gathered candidates and forces a fresh gather — which is why time-limited credentials should be fetched before the peer connection is created, not after.
Step 2 — Apply Filtering & Priority Algorithms
Not every discovered path is viable, and some are actively harmful (leaking a VPN interface, routing over an unreachable IPv6 link-local address). Enforce transport policy before signalling begins.
- Transport policy: Set
iceTransportPolicy: 'relay'for compliance-heavy environments. This drops host and srflx candidates entirely, routing all traffic through your TURN infrastructure. Expect 20–40 ms of added one-way latency in exchange for predictable, auditable paths. - Dual-stack filtering: Drop IPv6 link-local (
fe80::/10) candidates — they never traverse NAT and only waste connectivity checks. If your infrastructure lacks symmetric IPv6 routing, prefer IPv4 to avoid asymmetric packet loss. The nuances are in IPv6 Dual-Stack ICE Handling. - Component mapping: Enable BUNDLE (
bundlePolicy: 'max-bundle') to multiplex all media over a single component, halving candidate-pair combinations and TURN allocations.
Every filtering decision above is a read of one field in the a=candidate line the ICE agent hands you, so it helps to know exactly which token you are matching against before you write the regex.
// Filter candidates as they are gathered, before they hit the wire
pc.onicecandidate = (event) => {
const c = event.candidate;
if (!c) return; // null = end-of-gathering, not a candidate
if (/fe80::/i.test(c.address || c.candidate)) return; // drop IPv6 link-local
signalingChannel.send(JSON.stringify({
type: 'candidate',
candidate: c.candidate,
sdpMid: c.sdpMid,
sdpMLineIndex: c.sdpMLineIndex
}));
};
mDNS Host Candidates and Server-Side Endpoints
The .local hostnames Chrome and Safari emit instead of raw private IPs exist to stop a web page fingerprinting your LAN layout without ever asking for a camera. The browser registers a random UUID hostname over multicast DNS on the local segment and publishes that in the candidate line; the remote browser resolves it by multicast, which only succeeds when both peers are on the same broadcast domain — exactly the case where a host candidate could have worked anyway. Once the page holds a granted camera or microphone permission, Chrome stops obfuscating and emits the real private address, so the same code path produces different candidate lines depending on permission state. That is a genuine trap in test suites: a test that grants fake media via --use-fake-device-for-media-stream sees raw IPs, while production users on the join screen see mDNS names.
The failure this creates is not browser-to-browser, it is browser-to-server. A media server, TURN relay or SIP gateway sitting in a datacenter cannot resolve a multicast hostname advertised in an office, so every host candidate a browser sends it is dead weight. Naïve server implementations still queue connectivity checks against them, block on DNS resolution, and add several seconds of latency before the srflx or relay pair is nominated. The correct server-side policy is to discard any remote candidate whose address ends in .local at parse time and rely on the srflx and relay candidates, which carry routable addresses by construction. Never try to “fix” this by resolving mDNS names in your signalling backend — the backend is on a different segment, so the lookup can only ever time out.
Interface leakage is the mirror-image problem on the client. Virtual adapters from VPN clients, Docker bridges (172.17.x.x), Hyper-V switches and VirtualBox host-only networks all present as legitimate interfaces, and each one adds a host candidate that will never be reachable by the remote peer. Ten interfaces on one machine means ten host candidates, each generating pairs against every remote candidate, and the check list grows multiplicatively. Filtering on the RFC 1918 ranges you actually deploy on — or simply capping the number of host candidates you forward — cuts both the gathering time and the STUN check volume without changing which pair eventually wins.
Step 3 — Synchronise Signalling Exchange
Filtered candidates must be transmitted without blocking the SDP Offer/Answer Lifecycle. A robust WebSocket Signaling Implementation ensures out-of-order delivery and state transitions are handled gracefully, with delivery typically under 10 ms.
Buffer incoming candidates if the remote SDP has not yet been applied, then flush them on setRemoteDescription() resolution to avoid InvalidStateError.
let pendingCandidates = [];
async function handleIncomingCandidate(init) {
if (pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(init)); // safe: remote desc set
} else {
pendingCandidates.push(init); // buffer until ready
}
}
async function onRemoteDescriptionSet() {
for (const c of pendingCandidates) {
await pc.addIceCandidate(new RTCIceCandidate(c)); // flush in arrival order
}
pendingCandidates = [];
}
Ordering is the subtle part. Candidates can arrive at the remote peer before the offer/answer exchange has fully settled, so a queue that buffers until remoteDescription is set — then flushes in arrival order — is mandatory, not optional. Out-of-order or dropped candidate messages degrade gracefully (ICE simply tries fewer pairs) but a candidate applied before the remote description throws InvalidStateError and aborts the negotiation. Keep the signalling channel idempotent: re-delivering the same candidate must be harmless, because at-least-once delivery is far easier to build than exactly-once.
Whether you stream each candidate the moment it arrives or wait for gathering to complete is the single biggest latency lever here — covered in depth below.
Step 4 — Verification
Confirm the connection nominated the pair you expected and that gathering completed without silent failures.
- States progress
new → gathering → complete. Set an explicit timeout (5 s) to abort gathering on unstable networks rather than hanging indefinitely. - Poll
getStats()at 1 s intervals and correlatelocal-candidate/remote-candidatewith the nominatedcandidate-pairto see which path actually carries media. - Trigger re-gathering on network handoffs (Wi-Fi → cellular) via
pc.restartIce(), capped at 3 retries; the sequencing that keeps frames flowing across the restart is worked through in Triggering an ICE Restart Without Dropping Media.
async function auditIceStats() {
const stats = await pc.getStats();
for (const r of stats.values()) {
if (r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') {
// confirm whether the live path is host, srflx, or relay
console.log(`Nominated RTT=${(r.currentRoundTripTime * 1000).toFixed(1)} ms`);
}
}
}
pc.onicecandidateerror = (e) => {
// 701 = STUN/TURN allocate failure, 401 = TURN auth rejected
console.error(`ICE error [${e.errorCode}] ${e.errorText} on ${e.url}`);
};
Consent Freshness and the Stats That Predict a Drop
A nominated pair is not a permanent fact. RFC 7675 requires each side to keep sending STUN binding requests over the live pair roughly every 5 seconds, and to tear the pair down if no response arrives within 30 seconds. This consent mechanism is what turns “the other end unplugged its Ethernet cable” into a state transition rather than a silent black screen, and it is also what keeps the NAT mapping and TURN permission alive on the middleboxes in between. On carrier NAT, where mappings can expire in under 30 s, consent checks are frequently the only traffic keeping the pinhole open during a muted, screen-off moment.
The consequence for monitoring is that consentRequestsSent on the nominated candidate pair is a better early-warning signal than iceConnectionState. When requestsSent keeps climbing while responsesReceived flatlines, the path is already dead; the state machine simply has not admitted it yet, and you have up to 30 seconds of head start to pre-emptively restart ICE before the user notices frozen video. Poll at 1 s intervals and derive the delta rather than reading absolute counters.
let prev = { req: 0, res: 0 };
async function watchNominatedPair() {
const stats = await pc.getStats();
for (const r of stats.values()) {
if (r.type !== 'candidate-pair' || !r.nominated) continue;
const dReq = r.requestsSent - prev.req; // checks + consent probes sent this second
const dRes = r.responsesReceived - prev.res; // successful STUN responses this second
prev = { req: r.requestsSent, res: r.responsesReceived };
// Requests flowing but no responses = path is gone, state has not caught up yet
if (dReq > 0 && dRes === 0) console.warn('consent failing, pre-emptive restart advised');
// availableOutgoingBitrate collapsing on a relay pair often means TURN-side congestion
console.log(r.availableOutgoingBitrate, r.currentRoundTripTime, r.state);
}
}
setInterval(watchNominatedPair, 1000); // 1 s cadence matches the getStats sampling baseline
Cross-check the numbers against a live dump before trusting them: the timeline in Reading chrome://webrtc-internals Dumps shows every candidate pair’s state transitions with timestamps, which makes it obvious whether a pair spent its life frozen, failed its first check, or succeeded and later lost consent. The three look identical in an aggregate connection-failure metric and require completely different fixes.
Section Deep-Dives
Each scenario below has its own focused guide:
- ICE Candidate Trickle vs Bulk Gathering — when to stream candidates incrementally (saving 200–800 ms of Time-to-First-Frame) versus waiting for
complete, with a bulk fallback timeout. - Traversing Symmetric NAT with TURN — why symmetric NAT defeats srflx candidates entirely and forces relay paths, plus the
iceTransportPolicyand TURN config that fixes it. - WebRTC over CGNAT — sub-30-second binding lifetimes, port exhaustion, keepalive tuning, and relay fallback on carrier networks.
- IPv6 Dual-Stack ICE Handling — happy-eyeballs-style pairing, IPv4/IPv6 prioritisation,
fe80link-local filtering, and per-browser differences.
The verification step is also where you catch the most expensive class of bug: a connection that appears to work in development because both peers are on the same LAN (nominating a host pair) but fails in production because the real path needed srflx or relay. Force iceTransportPolicy: 'relay' in at least one CI path so the relay is exercised deterministically rather than only when a tester happens to be behind symmetric NAT. Pair that with getStats() assertions that the nominated pair is the type you expect, not merely that iceConnectionState reached connected.
Edge Cases & Browser Quirks
- Chrome (≥ 90): Exposes mDNS
.localhost candidates by default unless the page already has camera/mic permission. WithiceCandidatePoolSizeset, host and srflx are pre-gathered before the offer, shaving gathering time. - Firefox: Controls ICE TCP via
media.peerconnection.ice.tcp; relay-only behaviour and candidate ordering can differ from Chromium. Firefox is stricter about emitting the finalnullend-of-candidates signal — rely oniceGatheringState, not just the null event. - Safari (WebKit): Restricts non-standard UDP ports and is conservative about IPv6 candidate generation. Always run TURN on 3478 and TLS 5349 (or 443) so Safari clients behind restrictive firewalls still reach a relay; the listener and certificate setup for that last case is detailed in Forcing TURN over TCP 443 on Locked-Down Networks.
- Mobile (all browsers): STUN mappings can refresh in under 30 s on carrier NAT, so srflx candidates gathered early may already be stale by the time the remote peer uses them — track that decay by Monitoring STUN Binding Success Rates per network type rather than in aggregate.
Version-Specific Behaviour Worth Pinning Down
Chrome’s gathering behaviour changed materially around M90 when the ICE agent stopped emitting a separate candidate for every interface on machines with many virtual adapters and began honouring the RTCIceTransportPolicy before allocation rather than filtering after. The practical effect: on modern Chrome, iceTransportPolicy: 'relay' genuinely prevents the STUN binding request from ever leaving the machine, so it is a privacy control, not just a candidate filter. On older builds the request was still sent and only the resulting candidate was suppressed — worth knowing if you are asserting “no traffic leaves except to our relay” in a compliance document.
Firefox emits srflx candidates with the raddr/rport fields set to the base host address, and until permission is granted it does not use mDNS at all — it simply withholds host candidates on some configurations. That difference means a Chrome-to-Firefox call on the same LAN may nominate a srflx pair where Chrome-to-Chrome nominates host, producing a measurable RTT difference in an otherwise identical test. When a Firefox client fails to connect while Chrome succeeds, the per-pair transition table in Diagnosing ICE Failures with Firefox about:webrtc is the fastest way to see which check actually timed out.
Safari on iOS adds a lifecycle dimension nothing else has: when the app or tab goes to the background, the OS may suspend the networking stack, consent checks stop, and 30 seconds later the pair is dead. Web apps cannot prevent this, so treat foreground restoration as a trigger to inspect connection state and restart ICE if the pair did not survive, rather than assuming the connection resumes. The same applies to WKWebView-hosted calls in native shells, where the host application’s background modes decide whether the socket lives at all.
Common Implementation Mistakes
- Hardcoded endpoints: Never bake a single STUN/TURN host into the client. Use environment-aware or geo-routed configuration so each user reaches the nearest relay — multi-region STUN alone cuts connect latency 40–60%, and the placement maths behind that number is in Choosing STUN Server Regions for Latency.
- Silent failures: Ignoring
onicecandidateerrormasks firewall blocks and expired TURN credentials. Always logerrorCodeandurl. - Over-filtering: Dropping all host candidates behind symmetric NAT without a TURN fallback guarantees connection failure.
- State hangs: Unhandled
iceGatheringStatetimeouts cause indefinite hangs on cellular. Wrap gathering in aPromise.race()with a 5 s deadline. - Premature transmission: Sending candidates before
setRemoteDescription()resolves throwsInvalidStateError. Queue and flush on state change.
Failure Mode: Gathering Completes, No Pair Ever Succeeds
This is the most common production ICE failure and the one that produces the least useful logs, because everything upstream reports success. iceGatheringState reaches complete, both peers exchange a healthy set of candidates, onicecandidateerror never fires, and then every pair goes in-progress → failed. The pattern means STUN checks are leaving both machines and arriving nowhere, which narrows the cause to three specific things.
The first is a TURN allocation that succeeded while the permission did not. TURN requires a CreatePermission or ChannelBind for each remote peer address before the relay will forward traffic from it; if your relay’s denied-peer-ip rules or a security group block the remote’s address family — very common when the remote is IPv6-only and the relay only permits IPv4 peers — the allocation stays healthy and every packet is silently discarded. Diagnosis: the relay candidate exists, bytesSent on the pair increases, bytesReceived stays at zero. Fix at the relay, not the client.
The second is an asymmetric UDP block, where one side’s firewall permits outbound UDP to 3478 but drops the return path from any other port. Because ICE checks are bidirectional, both directions must survive for a pair to reach succeeded; a one-way path fails the pair while looking perfectly healthy in a tcpdump on the permissive side. Diagnosis: capture on both hosts and compare — the request appears on the wire at the sender and never arrives at the receiver. Fix with a TURN listener on TCP 443, which restrictive networks almost never block.
The third is a credential expiry race. Time-limited TURN credentials are typically valid for a short window; if the client caches a configuration object across a long-lived page session, the allocation issued at page load can outlive its credential by the time an ICE restart re-allocates, returning 401 mid-restart. Diagnosis: onicecandidateerror with errorCode 401 only on restart, never on first connect. Fix by refetching credentials immediately before every restartIce(), and treat the distinction between a recoverable stall and a terminal failure as documented in Disconnected vs Failed ICE States — restarting on every disconnected blip burns your three-retry budget on transient Wi-Fi noise that would have healed on its own.
FAQ
How do I force WebRTC to use only TURN relays for compliance?
Set iceTransportPolicy: 'relay' in the RTCPeerConnection config. This suppresses host and srflx candidates so all media traverses your audited TURN infrastructure.
Why does media latency spike despite successful signalling?
ICE is likely stuck in gathering or failing candidate-pair validation. Verify UDP 3478 reachability, check firewall rules, and pre-warm candidates with iceCandidatePoolSize: 4.
How many candidates should a typical peer generate?
Usually 3–10: one or two host, one srflx per STUN server, and one relay per TURN allocation. A peer emitting dozens usually has multiple unfiltered interfaces (VPN, virtual adapters) leaking — filter them.
What triggers onicecandidateerror?
STUN/TURN allocation failure (errorCode 701), auth rejection (401), or a blocked port. Log the payload, back off exponentially, and call pc.restartIce() (max 3 attempts) if the connection degrades.
Why does the connection pick a relay pair when both peers clearly have public addresses?
Almost always because the relay pair succeeded first and nomination is aggressive. If a srflx pair is checked but its response arrives after the relay pair has already been nominated and media has started, ICE keeps the working pair rather than churning the transport. It is not a bug, but it costs you 20–40 ms one-way and a relay slot. Reduce the odds by cutting the candidate count so the check list drains faster, and confirm with getStats() whether the srflx pair reached succeeded at all — if it went straight to failed, the topology genuinely needs the relay and no amount of tuning changes that.
Should gathering candidates for a second peer connection reuse the first one’s results?
No, and the browser will not let you. Candidates are bound to the sockets an individual RTCPeerConnection allocated, so a candidate line copied from one connection to another names a port the second connection is not listening on and every check against it fails. In a multi-party call, the correct way to avoid n separate gathering rounds is a single connection to a media server rather than a mesh of connections between peers — one gathering round, one TURN allocation, one set of consent checks per participant instead of one per pair.
How long should I wait before declaring gathering failed?
Cap it at 5 s. Host candidates appear in single-digit milliseconds, srflx typically within one RTT of the STUN server, and relay after the TURN Allocate handshake — so a peer that has produced nothing after 5 s is blocked, not slow. Race the gathering promise against that deadline and proceed with whatever candidates exist; a connection attempt on host-only candidates that fails fast is more useful than an indefinite spinner, and trickle exchange means late candidates can still be added afterwards.
Related: continue with the WebRTC Protocol Stack & Signaling Servers guide, or dig into Traversing Symmetric NAT with TURN, WebRTC over CGNAT, and ICE Candidate Trickle vs Bulk Gathering.