Connection Recovery & ICE Restart: Surviving Network Changes Mid-Call
A call that has been running for twenty minutes has already solved every hard problem once: NAT traversal succeeded, DTLS completed, codecs were agreed, and the encoder settled on a bitrate. Then the laptop lid moves, the phone leaves the building, or the CGNAT box rotates its mapping, and the 5-tuple the whole session was pinned to stops existing. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it covers how to detect that loss within seconds, replace the transport underneath a live session, and prove the replacement worked — without tearing down DTLS, re-keying SRTP, or restarting the encoder.
The implementation goal is narrow and measurable: from the moment packets stop arriving, reach a nominated candidate pair again in under 3 seconds on a good network and under 8 seconds on a mobile handover, using at most three restart attempts, and with media resuming on the same SSRCs the receiver was already decoding.
Step 1 — Detect degradation before the stack gives up
Two state machines report connection health and they are not the same machine. RTCPeerConnection.iceConnectionState aggregates the ICE transports only: it moves checking → connected → completed, and drops to disconnected when consent checks stop being answered, then failed when every candidate pair has been invalidated. RTCPeerConnection.connectionState aggregates ICE and DTLS, so it can sit in connecting while ICE has already reported connected (the DTLS handshake is still running), and it is the state your application logic should branch on. The legacy iceConnectionState is still the more granular signal for diagnosing where the failure is, which is why production code listens to both and logs them with timestamps.
| Signal | Aggregates | Useful transition | What it tells you |
|---|---|---|---|
iceConnectionState |
ICE transports | connected → disconnected |
Consent checks are going unanswered on the nominated pair |
iceConnectionState |
ICE transports | → failed |
Every pair in the checklist is dead; no path exists |
connectionState |
ICE + DTLS | connected → disconnected |
Same trigger, debounced slightly differently per engine |
connectionState |
ICE + DTLS | → failed |
Terminal in Chrome; only a restart or a new connection recovers |
iceGatheringState |
Local gathering | complete → gathering |
Confirms a restart actually began re-gathering |
The clock behind those transitions is consent freshness, specified in RFC 7675. Once a pair is nominated, the agent keeps sending STUN binding requests over it — not for connectivity discovery, but to prove the remote peer still wants to receive traffic, so a hijacked or abandoned 5-tuple cannot be used to flood a victim. Those requests go out roughly every 5 seconds with randomisation applied so a large deployment does not synchronise its refresh traffic, and RFC 7675 sets a hard ceiling of 30 seconds without a valid response before consent must be considered revoked. Browsers are far more impatient than the ceiling: in practice a handful of consecutive unanswered checks — about 15 seconds of silence — invalidates the pair, and disconnected appears after the very first missed exchange.
Waiting for the stack to reach failed is therefore a design choice that costs you ten seconds of dead air. A production detector fires much earlier by combining the state events with a stalled-bytes check sampled from getStats() on a 1 s interval. Bytes are the ground truth: connectionState can stay connected while a NAT rebinding silently blackholes traffic, and the consent timer will eventually notice, but a receiver counting zero new bytes for 2 seconds already knows.
// Degradation detector: state events give the fast signal, byte deltas give the truth.
const health = { lastBytes: 0, stalledSamples: 0, degradedSince: null };
pc.addEventListener('connectionstatechange', () => {
const s = pc.connectionState;
// Stamp the first moment we left 'connected' so the grace window is measurable.
if (s === 'disconnected' || s === 'failed') health.degradedSince ??= performance.now();
if (s === 'connected') { health.degradedSince = null; health.stalledSamples = 0; }
console.info(`[ice] connectionState=${s} iceConnectionState=${pc.iceConnectionState}`);
});
setInterval(async () => {
const report = await pc.getStats(); // 1 s cadence is enough; 200 ms just burns CPU
let pair = null;
for (const s of report.values()) {
// Only the succeeded+nominated pair is carrying media right now.
if (s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded') pair = s;
}
if (!pair) return;
const moved = pair.bytesReceived > health.lastBytes; // strictly increasing on a live path
health.lastBytes = pair.bytesReceived;
health.stalledSamples = moved ? 0 : health.stalledSamples + 1;
// Two consecutive stalled samples ≈ 2 s of blackhole, well before consent expiry.
if (health.stalledSamples >= 2) health.degradedSince ??= performance.now();
}, 1000);
The distinction between a transient disconnected and a terminal failed drives every decision that follows, and the engine-by-engine timing of those two states is unpacked in Disconnected vs Failed ICE States.
Step 2 — Decide: restart, wait, or rebuild
An ICE restart is not free. It re-runs gathering against every configured STUN and TURN server, allocates a fresh relay, and pushes an offer/answer round trip through your signaling channel. Fire it on every flap and you will convert a 900 ms self-healing blip into a 3-second outage, and you will hammer your TURN capacity with duplicate allocations during exactly the network conditions where it is already stressed.
Four inputs decide the question. First, which state you are in: failed warrants an immediate restart because Chrome never recovers from it on its own, while disconnected deserves a grace window of 2–3 seconds because the stack frequently re-validates the same pair without help. Second, whether the local network actually changed: an online event, a navigator.connection change event, or a new local interface appearing means the old candidates are provably stale, so waiting is pointless — restart after a short 300 ms debounce that lets the OS finish assigning an address. Third, the retry budget: three attempts, no more. Fourth, whether credentials are still valid, because a restart that re-gathers against an expired TURN credential fails at the allocate step and burns an attempt for nothing.
The backoff schedule matters as much as the cap. Exponential spacing of 1 s, 2 s, 4 s with ±20% jitter gives the network time to settle between attempts — a cellular bearer that is still negotiating will fail all three back-to-back attempts inside one second and leave you with nothing. Each attempt also gets its own 3–5 s fallback timeout: if the restart has not reached connected in that window, the attempt is written off and the next one scheduled, rather than leaving an orphaned restart in flight that races with its successor.
// Restart scheduler: bounded attempts, jittered exponential backoff, one in flight at a time.
const RESTART = { attempts: 0, max: 3, inFlight: false, timer: null };
function backoffMs(attempt) {
const base = 1000 * 2 ** attempt; // 1 s, 2 s, 4 s
return Math.round(base * (0.8 + Math.random() * 0.4)); // ±20% jitter avoids sync storms
}
function scheduleRecovery(reason) {
if (RESTART.inFlight || RESTART.timer) return; // never overlap attempts
if (RESTART.attempts >= RESTART.max) return rebuildConnection(reason); // budget spent
const delay = reason === 'network-change' ? 300 : backoffMs(RESTART.attempts);
RESTART.timer = setTimeout(async () => {
RESTART.timer = null;
RESTART.attempts += 1;
RESTART.inFlight = true;
try {
await performIceRestart(); // see Step 3
// 3–5 s fallback: if we are not connected by then, this attempt is dead.
await waitForConnected(4000);
RESTART.attempts = 0; // success resets the budget
} catch (err) {
console.warn(`[ice] restart ${RESTART.attempts}/${RESTART.max} failed: ${err.message}`);
scheduleRecovery(reason); // next attempt, longer backoff
} finally {
RESTART.inFlight = false;
}
}, delay);
}
// Interface changes are provable staleness — skip the grace window entirely.
window.addEventListener('online', () => scheduleRecovery('network-change'));
navigator.connection?.addEventListener('change', () => scheduleRecovery('network-change'));
Interface transitions deserve their own handling because the timing and the candidate set both change; the mobile-specific version of this logic is worked through in Handling Wi-Fi to Cellular Network Handover.
Step 3 — Execute the restart
There are two ways to start one and they differ in who owns the offer. pc.restartIce() sets an internal flag that marks the next generated offer as a restart and fires negotiationneeded, which means it composes cleanly with a perfect-negotiation implementation: your existing negotiationneeded handler builds and sends the offer exactly as it would for any other renegotiation, and glare handling still applies. pc.createOffer({ iceRestart: true }) skips the flag and produces the restart offer directly, which is what you need when you drive negotiation manually, when you must restart while negotiationneeded is suppressed, or on older Safari builds where restartIce() is unavailable. Both produce the same wire effect: the offer carries a fresh a=ice-ufrag and a=ice-pwd, which is the only thing that tells the remote peer this is a restart rather than an ordinary update.
Only the offerer can initiate. If the peer that noticed the failure is the answerer, calling restartIce() there makes it the offerer for this exchange — fine under perfect negotiation, dangerous under a hand-rolled state machine that assumes a fixed role. The remote side must answer with its own new ufrag/pwd pair; an answer that echoes the old credentials means the remote stack ignored the restart, and you will sit in checking until the fallback timeout fires.
Before generating the offer, refresh anything time-bound. TURN credentials issued with a short HMAC TTL are the classic trap: the call outlived the credential, gathering silently produces no relay candidates, and the restart fails on a network where only the relay path would have worked. Call setConfiguration() with fresh iceServers first — it takes effect on the next gathering pass, which is precisely what a restart triggers. The credential lifetime trade-off itself is covered in Time-Limited TURN Credentials with HMAC.
// Execute one restart attempt. Assumes a perfect-negotiation style negotiationneeded handler.
async function performIceRestart() {
// 1. Refresh short-TTL TURN credentials so re-gathering can actually allocate a relay.
const fresh = await fetch('/api/ice-servers').then(r => r.json());
pc.setConfiguration({ ...pc.getConfiguration(), iceServers: fresh.iceServers });
// 2. Preferred path: flag the restart and let negotiationneeded own the offer.
if (typeof pc.restartIce === 'function') {
pc.restartIce(); // fires negotiationneeded; glare rules still apply
return;
}
// 3. Fallback for engines without restartIce(): drive the offer manually.
if (pc.signalingState !== 'stable') {
throw new Error(`cannot restart from signalingState=${pc.signalingState}`);
}
const offer = await pc.createOffer({ iceRestart: true }); // forces new ufrag/pwd
await pc.setLocalDescription(offer);
// The fingerprint must be identical to the previous offer or DTLS will re-handshake.
signaling.send({ type: 'offer', sdp: pc.localDescription.sdp, restart: true });
}
// Read the credentials out of SDP to prove the restart is real, not an ordinary offer.
function iceUfrag(sdp) {
return (sdp.match(/^a=ice-ufrag:(.+)$/m) || [])[1] ?? null; // differs after a true restart
}
Server-side there is a capacity consequence worth planning for. Re-gathering allocates a second relay before the first one expires, so every restarting client holds two allocations for the remainder of the old allocation’s lifetime. On a coturn deployment serving a few thousand sessions, a regional Wi-Fi outage that restarts every client at once will double relay port consumption within the ports range for several minutes.
# coturn: an ICE restart holds a second allocation until the old one ages out.
min-port=49152
max-port=65535
# Allow roughly 2x steady-state allocations per user so restarts are not rejected.
user-quota=12
# Keep allocation lifetime short enough that abandoned relays free ports quickly.
max-allocate-lifetime=600
# Nonce rotation forces re-auth mid-call; keep it longer than your credential TTL.
stale-nonce=600
Recovering the transport is not renegotiating the session
These two operations are constantly conflated and they touch disjoint parts of the connection. An ICE restart replaces the transport — credentials, candidates, pairs, the nominated path. Renegotiation changes the session — tracks, codecs, direction, transceiver layout. A restart offer carries the same m-lines, the same payload types, the same SSRCs, and critically the same DTLS fingerprint, so the DTLS association and the SRTP keys derived from it survive intact and the receiver keeps decoding the same streams. Adding a camera or switching a codec never needs iceRestart: true, which is the subject of SDP Renegotiation Without Dropping Streams.
Step 4 — Verification in getStats()
“It reconnected” is not a verification. Four observations, taken from a single getStats() snapshot plus one before/after comparison, prove that the transport was genuinely replaced and that the session survived it. First, RTCTransportStats.selectedCandidatePairChanges must have incremented — this counter is the cleanest evidence that re-nomination happened. Second, the nominated candidate-pair must have a different id and a plausible currentRoundTripTime; on a relay path expect the extra 20–40 ms one-way that a TURN hop adds. Third, RTCTransportStats.dtlsState must still read connected with an unchanged srtpCipher, proving no re-handshake occurred. Fourth, inbound-rtp.framesDecoded must be advancing again, because a nominated pair with no frames means you fixed the path and broke the media.
// Snapshot the transport before the restart, compare after, and assert all four properties.
async function snapshotTransport() {
const report = await pc.getStats();
const out = { pairId: null, changes: 0, dtls: null, cipher: null, frames: 0, rtt: null };
for (const s of report.values()) {
if (s.type === 'transport') {
out.changes = s.selectedCandidatePairChanges ?? 0; // increments on re-nomination
out.dtls = s.dtlsState; // must stay 'connected'
out.cipher = s.srtpCipher; // must be byte-identical after
}
if (s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded') {
out.pairId = s.id; // a new id means a new path
out.rtt = s.currentRoundTripTime; // relay adds 20–40 ms one-way
}
if (s.type === 'inbound-rtp' && s.kind === 'video') {
out.frames = s.framesDecoded ?? 0; // must advance post-recovery
}
}
return out;
}
function assertRecovered(before, after) {
const checks = {
renominated: after.changes > before.changes, // transport picked a new pair
newPath: after.pairId !== before.pairId, // and it is genuinely different
dtlsIntact: after.dtls === 'connected' && after.cipher === before.cipher,
mediaMoving: after.frames > before.frames // decoder is consuming again
};
// Log the whole object: a single false value names the failure precisely.
console.info('[ice] recovery verification', checks, `rtt=${after.rtt}s`);
return Object.values(checks).every(Boolean);
}
Run the comparison 2 seconds after connectionState returns to connected, not immediately — framesDecoded needs a moment, and on a video stream the decoder may be waiting on a keyframe that the sender has not produced yet. If mediaMoving stays false while the other three pass, request a keyframe rather than restarting again; you recovered the transport and are now looking at a decoder-state problem. In chrome://webrtc-internals the same evidence appears as a second googCandidatePair block going active while the DTLS transport graph shows no discontinuity, and the SDP history pane shows two offers with different a=ice-ufrag values and an identical a=fingerprint.
A useful pre-ship checklist for the recovery path:
-
srtpCipher - Restarting during an in-flight renegotiation does not leave
signalingStatestuck offstable
Edge Cases & Browser Quirks
- Chrome treats
failedas terminal. Since the state-machine alignment in Chrome 71,connectionState === 'failed'never returns toconnectedon its own. There is no point polling and hoping; the only exits are a restart or a newRTCPeerConnection. Chrome also surfacesdisconnectedroughly 5 seconds after consent stops, thenfailedaround 15 seconds later. - Firefox flaps to
disconnectedfar more eagerly. Firefox reportsdisconnectedon brief packet loss that Chrome absorbs silently, and it frequently self-heals within 1–2 seconds. Without a grace window you will issue restarts on Firefox that Chrome never needed on the same network. Firefox also historically kepticeConnectionStateandconnectionStateless tightly coupled, so branch onconnectionStateand use the ICE state only for diagnostics. - Safari and
restartIce()availability.restartIce()landed in Safari 15.4; earlier WebKit builds, including many iOS 15 WKWebView embeddings, only supportcreateOffer({ iceRestart: true }). Feature-detect rather than version-sniff, because WKWebView on iOS reports the OS WebKit version regardless of the host app. - iOS background suspension looks like a permanent
disconnected. Backgrounding a tab or app suspends ICE activity; on resume the state does not recover by itself. Listen forvisibilitychangeand force a restart on foreground rather than waiting for a transition that will never arrive. - Restarting during renegotiation. If
signalingStateishave-local-offerwhen you decide to restart,createOffer({ iceRestart: true })will throw. Either wait forstableor userestartIce(), which defers the flag until the next negotiation cycle and composes with glare handling. - SFU certificate regeneration. A media server that mints a new DTLS certificate for its restart answer changes the fingerprint, which forces a full DTLS handshake and a 100–300 ms media gap that a browser-to-browser restart never has. Pin the certificate for the session lifetime.
Common Implementation Mistakes
- Restarting on the first
disconnected. Mostdisconnectedevents on Firefox and mobile Chrome resolve without help. A 2–3 s grace window converts a sub-second blip into a non-event instead of a 3-second renegotiation. - Unbounded retry loops. No cap means a client that lost its network entirely will re-gather against your STUN and TURN servers forever. Three attempts, exponential backoff, then rebuild or surface the failure to the user.
- Restarting with stale TURN credentials. Gathering succeeds, relay allocation fails with a 401, and the restart silently produces host and reflexive candidates only. Always refresh
iceServersviasetConfiguration()before the restart offer. - Assuming a restart re-keys media. It does not, and that is the point. Code that tears down and rebuilds senders “to be safe” after a restart throws away working encoder state and adds hundreds of milliseconds of black frames.
- Treating the signaling socket and the media path as one failure. A dropped WebSocket does not mean the media path broke, and restarting ICE when you cannot even deliver the offer wastes an attempt. Buffer the offer until signaling is back, then send it.
- Not verifying the remote answered with new credentials. If the answer echoes the previous
ice-ufrag, the remote ignored your restart. Detect it in the SDP and escalate to a rebuild instead of waiting out the fallback timeout. - Skipping the interface-change signal. Waiting for consent to expire after a handover throws away 5–10 seconds that
navigator.connectionchange events would have saved.
FAQ
Does an ICE restart interrupt audio and video? Only for the time it takes to nominate a new pair — typically 200–800 ms with trickle enabled, longer if a relay allocation is needed. The DTLS association, SRTP keys, SSRCs, and encoder state all survive, so there is no re-handshake and no codec renegotiation. Perceptually it looks like a brief freeze, not a reconnect.
Should both peers call restartIce() when they both detect the failure?
No. Pick one initiator — the impolite peer under perfect negotiation, or the participant with the lower session ID — and let the other side simply answer. Simultaneous restarts create glare, and the rollback that resolves it costs more time than the restart itself.
Why does my restart succeed but media never resumes?
Almost always a decoder waiting on a keyframe, not a transport problem. Verify selectedCandidatePairChanges incremented and bytesReceived is climbing on the new pair; if both are true but framesDecoded is flat, request a keyframe from the sender. Restarting again will not help.
How is this different from just re-gathering candidates? Re-gathering alone produces new candidates that the remote peer will reject, because ICE authenticates every connectivity check with the negotiated ufrag/pwd. The restart is what rotates those credentials and rebuilds the checklist on both sides; the mechanics of doing that without a media gap are detailed in Triggering an ICE Restart Without Dropping Media, and the filtering rules that decide which candidates come back live in ICE Candidate Gathering & Filtering.
Related: this section sits under the WebRTC Protocol Stack & Signaling Servers guide, and the recovery logic belongs inside the transitions modelled in Signaling State Machine Patterns; for the hands-on work see Triggering an ICE Restart Without Dropping Media, Handling Wi-Fi to Cellular Network Handover, and Disconnected vs Failed ICE States.