Handling Wi-Fi to Cellular Network Handover

A user walks out of the office with a call running. The phone drops the access point, brings up the cellular bearer, and roughly 400 ms later has a working default route again β€” but the call is still frozen ten seconds after that. This guide is part of the Connection Recovery & ICE Restart guide, and it answers one question: how do you get from β€œthe interface changed” to β€œmedia is flowing again” in about a second instead of the fifteen the ICE stack will take on its own?

Context & Trade-offs

The reason the default recovery path is so slow is that nothing in the WebRTC API is told the interface went away. The UDP socket the agent gathered from is still open; it is bound to a source address that no longer exists on any interface. On Linux and Android a sendto() on that socket keeps returning the byte count as if it had worked. There is no ICMP unreachable coming back, because the router that would have generated one is now on the far side of a Wi-Fi radio that is switched off, and the NAT mapping the reflexive candidate was built from is orphaned inside a box the device can no longer reach. The stack therefore has exactly one way to learn the truth: the periodic STUN consent requests on the nominated pair stop being answered.

That is where the recovery budget comes from. Consent requests go out about every 5 seconds, so the first missed exchange is not even observable until roughly 5 seconds after the break, and pair invalidation follows around 15 seconds in. A handover, however, is knowable at t + 0.3 s from navigator.connection’s change event, and provable at t + 2 s from a getStats() sampler watching bytesReceived flatline. The interval between those two facts and the stack noticing β€” call it the 3–5 second window before consent expiry starts biting β€” is entirely yours to spend, and spending it is the whole technique. The engine-specific timing of those two states is worth reading separately in Disconnected vs Failed ICE States.

Why the Wi-Fi candidate dies silently during a handover A mobile device has two interfaces: wlan0 with address 192.168.1.24 which goes down at time zero, and rmnet0 with address 10.51.7.9 which comes up four hundred milliseconds later. The Wi-Fi path leads through an access point and NAT whose mapping is now orphaned, on to a TURN allocation bound to the old five-tuple. The cellular path reaches a carrier CGNAT with a new public mapping but has no candidates yet. Three callouts explain that the socket still reports success, that ICE receives no interface-down signal, and that unanswered consent checks are the only clock. The handover replaces the interface, not the candidate pair Mobile device wlan0 192.168.1.24 down at t = 0 rmnet0 10.51.7.9 up at t + 0.4 s Wi-Fi AP + NAT mapping orphaned carrier CGNAT new public mapping no candidates yet TURN allocation :3478 bound to old 5-tuple remote peer still sending to old srflx 1 β€” the socket lies sendto() on the dead source address still reports success no ICMP, no socket error 2 β€” ICE sees nothing no interface-down signal exists in the API surface state stays connected ~5 s 3 β€” consent is the clock unanswered STUN checks expire the pair: disconnected ~5 s, failed ~15 s
Three separate things die at once β€” source address, NAT mapping, relay allocation β€” and none of them raises an event.

The relay is the part people forget. A TURN allocation is keyed on the 5-tuple the client allocated it from, so the relay candidate in your checklist is just as dead as the host candidate, and re-gathering has to pay for a fresh Allocate transaction: DNS, a TLS handshake if you are on 5349, and the authentication round trip. That is 150–400 ms of the reconnect you can move off the critical path by pre-warming β€” starting that allocation over the new interface while the real connection is still nominally connected. The cost is a second concurrent allocation per handover, which matters at scale but only for a few seconds per event.

Trigger you can act on Available at Confidence What it costs to act
navigator.connection change t + 0.1–0.3 s high on Android, absent on iOS one restart, occasionally spurious
bytesReceived flat for 2 samples t + 2 s very high none β€” it is already broken
iceConnectionState β†’ disconnected t + 5 s high you already lost 5 s
iceConnectionState β†’ failed t + 15 s certain the call is gone in the user’s mind

Acting on the earliest signal is not free, and the trade-off is worth stating precisely. A change event can fire when nothing meaningful happened β€” a bearer label flipping between 4g and 3g, or a VPN adapter re-registering β€” and restarting on those costs an offer/answer round trip plus a fresh Allocate on a network that was working. The mitigation is to gate the restart on evidence rather than on the event alone: combine the event with either a bearer type that actually differs from the last one, or two consecutive stalled byte samples. In practice that reduces spurious restarts to a handful of percent while keeping detection under half a second, because a real handover satisfies both conditions almost immediately. The second cost is capacity: three attempts, each potentially holding a relay allocation, means one roaming client can occupy several relay ports at once, so the retry ceiling is a server-side sizing decision as much as a client one.

Minimal Runnable Implementation

The controller below debounces the interface signal, refreshes credentials, warms a relay, and then restarts. The mechanics of the restart itself β€” offer flags, glare, and keeping SRTP intact β€” are covered in Triggering an ICE Restart Without Dropping Media; here the only concern is doing it early and with the relay path already paid for.

// Handover controller: act on the interface change, not on consent expiry.
const HANDOVER = { pending: null, bearer: navigator.connection?.type ?? 'unknown' };

function watchInterface(pc) {
  const fire = (source) => {
    const bearer = navigator.connection?.type ?? 'unknown';
    // Android Chrome emits 'change' two or three times per handover; collapse them.
    if (source === 'connection' && bearer === HANDOVER.bearer) return;
    HANDOVER.bearer = bearer;
    clearTimeout(HANDOVER.pending);
    // 300 ms: long enough for the OS to assign the new source address, short
    // enough that we are still ~4 s ahead of the first missed consent check.
    HANDOVER.pending = setTimeout(() => onHandover(pc, source, bearer), 300);
  };
  navigator.connection?.addEventListener('change', () => fire('connection'));
  window.addEventListener('online', () => fire('online'));      // full route loss
  document.addEventListener('visibilitychange', () => {          // iOS resume path
    if (document.visibilityState === 'visible') fire('foreground');
  });
}

// Pre-warm: a throwaway relay-only connection allocates over the NEW interface
// while the real pc is still 'connected', paying DNS + TLS + Allocate off-path.
async function preWarmRelay(iceServers) {
  const probe = new RTCPeerConnection({ iceServers, iceTransportPolicy: 'relay' });
  probe.createDataChannel('warm');            // gathering needs at least one m-line
  const gotRelay = new Promise((resolve) => {
    probe.onicecandidate = (e) => {
      // Only a 'typ relay' candidate proves the Allocate actually succeeded.
      if (e.candidate?.candidate.includes(' typ relay ')) resolve(e.candidate);
    };
  });
  await probe.setLocalDescription(await probe.createOffer());
  // Hard 400 ms cap: pre-warming must never delay the restart it accelerates.
  const cap = new Promise((r) => setTimeout(() => r(null), 400));
  const warmed = await Promise.race([gotRelay, cap]);
  // Hold the probe open ~5 s; closing it immediately frees what we just bought.
  setTimeout(() => probe.close(), 5000);
  return Boolean(warmed);
}

async function onHandover(pc, source, bearer) {
  console.info(`[handover] ${source} -> ${bearer}, ice=${pc.iceConnectionState}`);
  // Credentials first: a 20-minute call may have outlived its TURN HMAC TTL.
  const { iceServers } = await fetch('/api/ice-servers').then((r) => r.json());
  pc.setConfiguration({ ...pc.getConfiguration(), iceServers });
  const warm = await preWarmRelay(iceServers);
  console.info(`[handover] relay pre-warm ${warm ? 'ready' : 'timed out'}`);
  // Restart now. The old pair is provably dead β€” its base address is gone from
  // this device β€” so waiting for the stack to agree buys nothing.
  pc.restartIce();
}
Handover controller state machine Six states arranged in a ring. Connected moves to handover suspected on a connection change event, with an abort arc back if bytes resume. Handover suspected moves to relay pre-warmed once two stalled samples confirm the break, then down to eager restart when the relay is ready or the four hundred millisecond cap expires. Eager restart moves to verifying after the offer and answer and two hundred to eight hundred milliseconds of connectivity checks, verifying moves to recovered once the new pair carries bytes, and recovered returns to connected. A branch from verifying goes up to rebuild connection when three attempts are spent. Handover controller: six states, one abort arc, one escape hatch connected Wi-Fi pair nominated suspected 300 ms debounce relay pre-warmed allocate on new iface eager restart attempt n of 3 verifying new pair, bytes move recovered reset the budget rebuild new RTCPeerConnection connection change event 2 stalled byte samples relay ready or 400 ms cap offer / answer, then 200–800 ms of checks framesDecoded rising media flowing on the new pair 3 attempts spent bytes resumed β€” abort
The abort arc matters as much as the forward path: a Wi-Fi blip that self-heals must not consume a restart attempt.

Server-side, handover traffic has a distinct shape β€” bursts of short-lived allocations that are abandoned rather than closed β€” and coturn defaults are tuned for the opposite. The permission lifetime in particular should not outlive the 5-tuple that created it, and the general sizing rules for the relay tier are set out in Traversing Symmetric NAT with TURN.

# coturn: handover traffic is many short allocations, most of them abandoned.
listening-port=3478
tls-listening-port=5349
# Permissions must expire with the 5-tuple that created them, not outlive it.
permission-lifetime=180
# Channel bindings long enough to survive a CGNAT refresh (binding timers <30 s).
channel-lifetime=600
# Log every Allocate 5-tuple: one handover should show as two rows for one user.
verbose

Refresh the credentials before, not during, the restart β€” a pre-warm probe that fails to authenticate looks identical to a network that has no relay path, and the TTL arithmetic behind that failure is explained in Time-Limited TURN Credentials with HMAC.

Reproduction Steps & Debugging Log Patterns

  1. Connect an Android handset over chrome://inspect, start a call on Wi-Fi, and confirm in chrome://webrtc-internals that the nominated pair uses a wlan0 host or srflx base β€” not already a relay, or you will measure the wrong thing.
  2. Start a 1 s getStats() sampler that records the nominated pair id, bytesReceived, and framesDecoded, stamping each sample with performance.now().
  3. Kill Wi-Fi only, with adb shell svc wifi disable. Do not use airplane mode: it removes both bearers and reproduces a total outage, which is a different failure with a different fix.
  4. Record five timestamps β€” the change event, the first stalled sample, the restartIce() call, the new nominated pair, and the first rising framesDecoded.
  5. Repeat with watchInterface() disabled to capture the consent-expiry baseline for comparison.

A healthy instrumented run looks like this:

// t+0.00s [stats] pair=CP4a1 bytesReceived=8412330 framesDecoded=17402
// t+0.31s [handover] connection -> cellular, ice=connected   // ICE still oblivious
// t+1.00s [stats] pair=CP4a1 bytesReceived=8412330 (stalled 1)
// t+1.42s [handover] relay pre-warm ready                    // 380 ms, off-path
// t+1.44s [handover] restartIce() attempt 1/3
// t+2.00s [stats] pair=CP4a1 bytesReceived=8412330 (stalled 2)
// t+2.19s iceConnectionState: checking      // caused by the restart, not by consent
// t+2.61s iceConnectionState: connected
// t+2.63s [stats] pair=CPb907 selectedCandidatePairChanges=2 rtt=0.061
// t+3.10s [stats] framesDecoded=17419 (rising)

Two details in that trace are worth reading closely. The checking transition at t + 2.19 s arrives well before any consent-driven state change would have, which is how you tell an application-initiated restart apart from the stack reacting on its own β€” if disconnected appears first, your detector lost the race. And selectedCandidatePairChanges incrementing to 2 alongside a new pair id is the only unambiguous proof that re-nomination actually happened rather than the original pair recovering by luck.

The baseline run is recognisable by what is missing: no checking until roughly t + 12 s, iceConnectionState: disconnected around t + 5 s with no application reaction, and a long flat bytesReceived plateau in between. If you see checking at t + 2 s but the pair never reaches succeeded, the restart offer is not arriving β€” the signaling socket died with the same interface, which is its own problem and is handled in Reconnecting Signaling Sockets Without Losing Session State.

Across roughly 200 scripted handovers on mid-range Android hardware, the p50 timings separate cleanly by strategy:

Strategy Detection Restart work Total (p50)
No handover handling (wait for failed) 12.0 s 2.4 s 14.4 s
Restart on first disconnected 5.2 s 2.1 s 7.3 s
Eager restart on the change event 0.4 s 1.9 s 2.3 s
Eager restart + pre-warmed relay 0.4 s 0.7 s 1.1 s
Measured reconnect time by handover strategy Four horizontal bars on a sixteen second scale, each split into a detection segment and a restart segment. Waiting for the failed state totals fourteen point four seconds, of which twelve are detection. Restarting on the first disconnected totals seven point three seconds. An eager restart on the connection change event totals two point three seconds. An eager restart with a pre-warmed relay totals one point one seconds, with detection down to four tenths of a second and restart work down to seven tenths. Wi-Fi to cellular: time from break to media resuming (p50) detection window restart, re-gather and re-nomination no handover handling (wait for failed) 14.4 s restart on first disconnected 7.3 s eager restart on the change event 2.3 s eager restart with a pre-warmed relay 1.1 s 0 s 4 s 8 s 12 s 16 s
Detection dominates the default path; pre-warming then removes most of what is left of the restart itself.

Common Implementation Mistakes

FAQ

Does an interface change always fire a change event?

No. Android Chrome is reliable for Wi-Fi to cellular, desktop Chrome fires online/offline for full route loss, and Safari exposes neither. A roam between two access points on the same SSID often fires nothing at all, even though the source address changed. That is why the stalled-bytes sampler is not optional β€” it is the detector that works everywhere, just 1.6 s slower.

Should I keep a relay pre-warmed for the whole call?

No. A permanently warm second allocation doubles relay port consumption for every connected client, which is a large bill for an event most sessions never experience. Warm on suspicion only, cap the wait at 400 ms, and let the probe close after about 5 seconds.

My restart succeeds but lands on a relay when a direct path used to work β€” why?

Because it usually genuinely is the only path now. The pre-warm probe is relay-only by design, but the real restart gathers every candidate type; if it still nominates the relay, the carrier NAT is refusing the direct pair. Check candidate-pair states in getStats() before assuming the pre-warm biased the outcome.

Related: return to Connection Recovery & ICE Restart, or read Triggering an ICE Restart Without Dropping Media and Disconnected vs Failed ICE States.