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.
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.
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.
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.
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.
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.
- Ignoring unique-local (
fc00::/7). ULA addresses behave like link-local for most peer pairs and usually belong in the filter list.
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.
Related: return to ICE Candidate Gathering & Filtering, and see ICE Candidate Trickle vs Bulk Gathering and Traversing Symmetric NAT with TURN.