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.
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();
}
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
- Connect an Android handset over
chrome://inspect, start a call on Wi-Fi, and confirm inchrome://webrtc-internalsthat the nominated pair uses awlan0host or srflx base β not already a relay, or you will measure the wrong thing. - Start a 1 s
getStats()sampler that records the nominated pair id,bytesReceived, andframesDecoded, stamping each sample withperformance.now(). - 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. - Record five timestamps β the
changeevent, the first stalled sample, therestartIce()call, the new nominated pair, and the first risingframesDecoded. - 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 |
Common Implementation Mistakes
- Waiting for
failedbecause βthe stack knows bestβ. On a handover the stack knows nothing until consent expires, and it is measurably 13 seconds behind an event the browser already delivered. Every one of those seconds is silent frozen video. - Restarting with no debounce. Fire
restartIce()at the instant thechangeevent lands and gathering runs before the new interface has an address: you re-gather the dead candidate set, burn an attempt, and then have to restart again. 300 ms of patience buys a correct candidate list. - Assuming the relay candidate survives the handover. It does not β the allocation is bound to the old 5-tuple. Code that keeps
iceTransportPolicy: 'relay'and expects an instant reconnect is waiting on a fresh Allocate it never accounted for. - Treating the cellular path as equivalent. After landing on a carrier bearer the reflexive candidate usually sits behind carrier-grade NAT, direct paths fail far more often, and the bandwidth estimator restarts from a conservative probe. Expect a relay path and 20β40 ms of extra one-way delay; the address-mapping side of this is covered in WebRTC over CGNAT.
- Shipping an Android-only detector.
navigator.connectiondoes not exist in Safari or iOS WKWebView, so the whole controller silently no-ops on the platform where handovers are most common. Fall back to the stalled-bytes sampler plusvisibilitychange, and verify it on-device as described in Debugging WebRTC on Safari and iOS WKWebView.
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.