IPv6 Dual-Stack ICE Candidate Handling
Dual-stack hosts gather both IPv4 and IPv6 candidates, and how ICE pairs and prioritises them decides whether a connection forms fast, forms slowly, or stalls on an unreachable address. This guide is part of the ICE Candidate Gathering & Filtering guide, and it addresses one decision: how to order, filter, and pair IPv4/IPv6 candidates so dual-stack peers connect reliably instead of wasting checks on dead addresses.
Context & Trade-offs
A dual-stack peer can produce two or more host candidates for a single interface — one IPv4, one or more IPv6 — plus reflexive candidates for each family. ICE runs connectivity checks across the cross-product of local and remote candidates, so naive dual-stack roughly doubles the check matrix. Done well, IPv6 gives you a direct, NAT-free path that often beats the IPv4 srflx/relay route; done badly, you burn time probing addresses that can never connect.
Two failure sources dominate. First, link-local addresses: every IPv6 interface has an fe80::/10 address that is only valid on its own link. These never traverse a router, so advertising them to a remote peer guarantees failed checks and wasted RTT — they must be filtered before they reach the wire. Second, asymmetric reachability: a peer may have IPv6 connectivity to the STUN server but not to the specific remote peer (or vice versa), so an IPv6 candidate pair that looks valid during gathering fails during checks. Reachability is also not stable for the life of a call — a laptop that drops off Wi-Fi onto a tethered cellular link can lose IPv6 entirely mid-session, which is why Handling Wi-Fi to Cellular Network Handover matters on the same hosts you are dual-stacking.
ICE’s priority formula and a happy-eyeballs-style approach handle this gracefully. Rather than committing to one family, you let both compete: higher priority nudges the preferred family to nominate first, but the other family remains a live fallback if the preferred path fails its checks. RFC 8445 recommends preferring IPv6 where available because direct IPv6 avoids NAT entirely — but only after fe80 and other unusable scopes are filtered. The trade-off is the doubled check matrix and slightly longer worst-case gathering; the payoff is a faster, NAT-free path for the growing share of IPv6-capable users, with IPv4 (and its TURN relay fallback) always available underneath.
The parallel to the Happy Eyeballs algorithm (RFC 8305) used by browsers and HTTP clients is deliberate. Happy Eyeballs avoids the classic “broken IPv6” stall — where a client commits to IPv6, waits for a long connection timeout, and only then retries IPv4 — by racing both families with a small head start for IPv6. ICE achieves the equivalent through its candidate-pair priority and the fact that all pairs are checked concurrently: an unreachable IPv6 pair simply loses the race instead of blocking the connection. The practical implication for your code is to not serialise the families yourself. Do not gather IPv6, wait, then gather IPv4 as a fallback; emit both as they are discovered and let ICE’s concurrent checks pick the winner. The only manual work is filtering the scopes that can never win — link-local and loopback — so they do not pad the check matrix with guaranteed failures.
How priority ordering favours IPv6 without forcing it
ICE gives each candidate priority = (2^24 × type preference) + (2^8 × local preference) + (256 − component ID). Type preference is fixed by RFC 8445 — 126 for host, 100 for server-reflexive, 0 for relay — so a relayed candidate can never outrank a host candidate no matter which family it belongs to. Family ordering therefore lives entirely in the 16-bit local-preference field, which the browser fills in on your behalf. Chrome derives that field from an interface ranking that places IPv6 above IPv4 on the same NIC, then subtracts a network cost — 0 for Ethernet and Wi-Fi, 10 for cellular, 50 for VPN adapters — which it also publishes as the network-cost extension attribute at the end of the candidate line. That is why on a tethered handset an IPv6 host candidate can rank below a Wi-Fi IPv4 host candidate: the interface penalty is larger than the family bonus. Firefox emits no network-cost at all and orders by family and interface index, so two browsers on identical hardware and the same LAN can legitimately nominate different pairs. Any test that asserts “IPv6 must win” is asserting a browser implementation detail, not a protocol guarantee.
Pair priority then folds both sides together as 2^32 × min(G,D) + 2 × max(G,D) + (G > D ? 1 : 0), where G and D are the controlling and controlled candidate priorities. Because the minimum term dominates, a peer that ranks its IPv6 candidate poorly drags the entire pair down the check list — your ordering is only ever as good as the ordering the far end sends you, which is another argument for filtering aggressively rather than re-ranking cleverly.
Minimal Runnable Implementation
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turn.example.com:3478', username: 'u', credential: 'p' }
],
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
});
// Filter unusable IPv6 scopes before candidates leave the client.
function isUsable(candidate) {
const addr = candidate.address || '';
if (/^fe80:/i.test(addr)) return false; // link-local: never routable
if (/^fc00:|^fd00:/i.test(addr)) return false; // unique-local: usually unroutable to peer
if (/^::1$/.test(addr)) return false; // IPv6 loopback
return true;
}
pc.onicecandidate = (e) => {
const c = e.candidate;
if (!c) return; // null = end-of-gathering
if (!isUsable(c)) {
console.debug('Filtered candidate:', c.address); // drop fe80 / loopback / ULA
return;
}
// Let both families through; ICE priority + checks pick the winner (happy-eyeballs)
signaling.send({ type: 'candidate', candidate: c.toJSON() });
};
// Inspect which family actually won
async function logNominatedFamily() {
const stats = await pc.getStats();
for (const r of stats.values()) {
if (r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') {
const local = [...stats.values()].find(x => x.id === r.localCandidateId);
// address tells you IPv4 (a.b.c.d) vs IPv6 (colon-separated)
console.log('Nominated local address:', local && local.address);
}
}
}
Do not strip IPv6 wholesale — that throws away the fast NAT-free path. Filter only the scopes that cannot route to a remote peer (fe80, ::1, and usually fc00::/7), then let ICE’s priority ordering and connectivity checks race the families like happy eyeballs.
Give the TURN server an AAAA record, or IPv6-only clients have no fallback
Client-side filtering is only half the job: the fallback path has to be reachable in the client’s own family. Mobile carriers increasingly hand out IPv6-only prefixes with NAT64/DNS64 in front, and the synthesis that lets those handsets reach IPv4 destinations only fires when the client resolves a hostname — DNS64 fabricates a AAAA record inside 64:ff9b::/96 and the carrier’s NAT64 gateway translates it. Configure turn:turn.example.com:3478, never an IPv4 literal: a literal skips DNS entirely, DNS64 never gets a chance to synthesise anything, and an IPv6-only handset simply cannot allocate a relay. The symptom is brutal and specific — every pair fails on one carrier while the same build works everywhere else. The same address-family reasoning drives relay reachability behind WebRTC over CGNAT, where the client’s visible family and the relay’s listening family have to overlap.
# coturn: listen on both families — one socket per listening address.
listening-ip=0.0.0.0
listening-ip=::
listening-port=3478
tls-listening-port=5349
# Advertise a relay address per family so RFC 6156 REQUESTED-ADDRESS-FAMILY
# allocations succeed for v4 and v6 clients alike.
relay-ip=203.0.113.10
relay-ip=2001:db8:100::10
# Ephemeral relay range; open the firewall across all of it for both families.
min-port=49152
max-port=65535
Verify both listeners independently — turnutils_uclient -6 against the hostname exercises the IPv6 allocation path that a browser on an IPv6-only carrier will take. The relay penalty is the same 20–40 ms one-way in either family, so there is no quality reason to prefer an IPv4 relay once the IPv6 listener works.
Reproduction Steps & Debugging Log Patterns
- On a dual-stack host, gather candidates with no filtering and log each
candidate’saddressandcandidateType. - Count how many
fe80::entries appear — these are pure waste; confirm they never appear in a succeeded pair. - Apply the
isUsablefilter and re-run; verify the check matrix shrinks and time-to-connecteddrops. - Force IPv6-only and IPv4-only runs to confirm both families independently reach
connectedon your network. - Repeat the failing run in Firefox and read the per-pair check table described in Diagnosing ICE Failures with Firefox about:webrtc, which shows retransmit counts per family that Chrome’s dump summarises away.
Expected log showing IPv6 winning cleanly after filtering:
// candidate host 192.168.1.20 (IPv4)
// candidate host 2001:db8::20 (IPv6 global)
// FILTERED fe80::1c2e:... <- link-local dropped
// candidate-pair (IPv6/IPv6) state: succeeded nominated: true
// candidate-pair (IPv4/IPv4) state: succeeded nominated: false <- live fallback
// iceConnectionState: connected
If IPv6 pairs sit in in-progress and never succeed while IPv4 connects, your IPv6 path has asymmetric reachability — the address is valid locally but unroutable to the peer. That is expected; ICE correctly falls back to IPv4. The timeline below is what a healthy dual-stack race looks like on the wire: three pairs advancing at once, one nominating early, one waiting as a fallback, and one failing without ever holding up the call.
When the filter silently does nothing: mDNS-obfuscated host candidates
Step 1 hides a trap that costs people an afternoon. Chrome since 76, Firefox since 68 and Safari since 12.1 replace the address of host candidates with a randomly generated <uuid>.local mDNS name whenever the page has not been granted a persistent camera or microphone permission. candidate.address then holds no colons and no dotted quad, so a regex looking for fe80: matches nothing, every host candidate flows through untouched, and the filter appears to work because no exception is thrown and the call still connects. The tell is a candidate row whose address column is a UUID ending in .local sitting next to server-reflexive rows that still carry real addresses; Reading chrome://webrtc-internals Dumps walks the same table field by field. The fix is not to defeat the obfuscation — it exists to stop pages harvesting local-network addresses — but to treat .local as pass-through and run the verification pass from an origin that already holds granted permission, at which point the browser emits raw addresses again and your scope filter has something to match. Budget for the resolution cost too: the receiving peer must answer a multicast query before it can even begin checking that pair, and the query is dropped outright across subnets or on networks that filter multicast, which turns an otherwise healthy host pair into a silent non-starter.
A pair that goes quiet after succeeding is a different situation from one that never succeeded at all, and the distinction drives whether you wait or restart — Disconnected vs Failed ICE States covers the transitions in detail.
Common Implementation Mistakes
- Advertising
fe80::link-local candidates. They never route off-link, so every check against them fails and adds latency — filter them client-side. - Stripping IPv6 entirely “to be safe”. This discards the fastest, NAT-free path for IPv6-capable users; filter scopes, not the whole family.
- Assuming IPv6 reachability is symmetric. A working IPv6 path to STUN does not imply a working path to the peer; keep IPv4 as a live fallback rather than committing to IPv6.
- Hard-coding family preference per OS. Chrome, Firefox, and Safari differ in default IPv6 candidate generation and ordering; rely on ICE priority and checks rather than guessing. iOS is the sharpest outlier here — carrier networks are frequently IPv6-only, so verify the filter under the conditions described in Debugging WebRTC on Safari and iOS WKWebView before shipping it.
- Ignoring unique-local (
fc00::/7). ULA addresses behave like link-local for most peer pairs and usually belong in the filter list. - Trusting
candidate.addressas the only source of truth. It is empty for redacted candidates and holds an mDNS name for host candidates on a permission-less origin; parse the rawcandidate.candidateSDP line when you need certainty about which family a candidate belongs to. - Caching the first gathered IPv6 address. Temporary privacy addresses (RFC 4941) rotate on a timer, typically once a day on Windows and macOS. The old address is deprecated rather than deleted, so the running pair survives on its consent-freshness checks, but the next ICE restart re-gathers from a different source address — re-read candidates after every restart instead of reusing the set you signalled at setup.
FAQ
Should I prefer IPv4 or IPv6 in dual-stack ICE?
Prefer IPv6 when both are usable — it is a direct path with no NAT — but keep IPv4 as a live fallback. Let ICE’s priority ordering plus connectivity checks pick the winner rather than forcing a family.
Do browsers handle fe80 filtering for me?
Not consistently. Behaviour differs across Chrome, Firefox, and Safari and across versions, so apply an explicit fe80::/::1/ULA filter in your onicecandidate handler.
Why do IPv6 candidate pairs sometimes never succeed?
Asymmetric reachability: the IPv6 address routes to your STUN server but not to the remote peer. ICE detects this through failed checks and falls back to IPv4 automatically — it is not a bug.
Does supporting both families double my relay cost?
No. A relay allocation happens only for the family that ends up winning, and only when no direct pair succeeds at all. In practice adding IPv6 lowers relay usage: a global IPv6 host pair connects directly in cases where the IPv4 side would have needed a relay behind symmetric NAT. Only the sessions that still fall back pay the 20–40 ms one-way relay penalty.
Should I filter fc00::/7 on a single-site corporate deployment?
No — make that one a config flag. Unique-local addresses are routable within a site, so between two hosts on the same enterprise network a ULA pair is a legitimate direct path, and filtering it pushes traffic onto the relay for no reason. Keep fe80:: and ::1 unconditional, since neither can ever produce a succeeded pair, and switch the ULA rule off only for deployments where you know both peers sit inside the same routing domain.
Related: return to ICE Candidate Gathering & Filtering, and see ICE Candidate Trickle vs Bulk Gathering and Traversing Symmetric NAT with TURN.