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.

Dual-stack candidate scopes and their filtering decisions Five rows comparing IPv6 global unicast, unique-local, link-local, loopback and IPv4 candidates by whether they reach a remote peer, whether a connectivity check can ever succeed, and the action to take. Which dual-stack candidates deserve a place in the check matrix Filter by scope, never by family — dropping all of IPv6 discards the NAT-free path Scope / prefix Reaches remote peer Check can succeed Action 2000::/3 global unicast Yes, routed Yes, often first Send fc00::/7 unique-local Rarely, same site only Almost never Filter fe80::/10 link-local No, dropped off-link Never Filter ::1 IPv6 loopback No Never Filter IPv4 host / srflx / relay Yes, via NAT or TURN Yes Send as fallback Dropping fe80 and ::1 removes 2 to 4 candidates per interface, and the cross-product shrinks quadratically with them.
Scope-by-scope filtering decisions for dual-stack candidates before they reach the wire.

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);
    }
  }
}
Decision path taken by every candidate in the onicecandidate handler A tree starting at the onicecandidate event, branching on the null end-of-gathering sentinel, then on unusable IPv6 scopes, and finally splitting signalled candidates into a high-priority IPv6 pair and an IPv4 fallback pair. Filter path taken by every gathered candidate onicecandidate(e) fires e.candidate === null ? yes Gathering complete no address in fe80:: / ::1 / fc00::/7 ? yes Drop, log FILTERED no signaling.send(c.toJSON()) IPv6 global: high-priority pair IPv4 host or srflx: fallback pair
Every candidate takes one of three exits: sentinel, filtered, or signalled into the race.

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

  1. On a dual-stack host, gather candidates with no filtering and log each candidate’s address and candidateType.
  2. Count how many fe80:: entries appear — these are pure waste; confirm they never appear in a succeeded pair.
  3. Apply the isUsable filter and re-run; verify the check matrix shrinks and time-to-connected drops.
  4. Force IPv6-only and IPv4-only runs to confirm both families independently reach connected on your network.
  5. 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.

Concurrent connectivity checks across both address families A millisecond timeline with three lanes: an IPv6 host pair that succeeds at 120 milliseconds and is nominated at 180, an IPv4 server-reflexive pair that succeeds at 260 milliseconds and is retained as a fallback, and an unroutable IPv6 pair whose four retransmits go unanswered until it fails at 800 milliseconds. Concurrent checks: the race decides, nothing blocks An unreachable family loses the race instead of stalling the call — this is why you never serialise gathering yourself IPv6 host pair succeeded 120 ms, nominated 180 ms IPv4 srflx pair succeeded 260 ms, held as live fallback IPv6, no route failed 800 ms 4 STUN retransmits, no response 0 ms 200 400 600 800 ms Nomination at 180 ms is unaffected by the pair still retransmitting — the failing family never gates the connected transition.
Three pairs checked in parallel: early IPv6 nomination, IPv4 held in reserve, dead IPv6 timing out harmlessly.

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

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.