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.

Consent freshness timeline after the media path breaks A time axis from zero to thirty seconds. Consent binding requests fire about every five seconds. The path breaks at zero, the first unanswered check moves the state to disconnected near five seconds, the candidate pair is invalidated and the state becomes failed near fifteen seconds, and RFC 7675 places a hard consent expiry at thirty seconds. An application restart trigger fires after a three second grace window. Nominated pair loses its path at t = 0 STUN consent binding requests, one per ~5 s (RFC 7675) filled = answered, hollow = no response restart trigger (3 s grace) connected disconnected failed 0 s 5 s 10 s 15 s 20 s 25 s 30 s path breaks first check unanswered pair invalidated RFC 7675 consent ceiling
Consent freshness sets the recovery budget: disconnected at the first missed check, pair invalidation around 15 s, a 30 s protocol ceiling.

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.

Restart-versus-wait decision tree A state change branches three ways. Disconnected starts a two to three second grace timer and checks whether the path recovered; a network change is debounced for three hundred milliseconds; failed goes straight to the retry gate. The gate allows up to three attempts with exponential backoff, otherwise the peer connection is torn down and rebuilt. degradation observed disconnected start 2–3 s grace timer interface changed debounce 300 ms failed never self-recovers bytes moving again? yes no no action attempts used < 3? yes no restart, backoff 1/2/4 s tear down and rebuild
Three entry conditions, one shared retry gate: grace for transients, immediate action for real interface changes, hard stop after three attempts.

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.

ICE restart offer/answer exchange and re-nomination Peer A calls restartIce and sends an offer with new ICE credentials through the signaling server to Peer B. Peer B applies it, re-gathers, and answers with its own new credentials. Both peers then run STUN connectivity checks on fresh candidate pairs and re-nominate, while the DTLS and SRTP association is left untouched. Peer A (offerer) Signaling Peer B (answerer) pc.restartIce() 0 ms offer: new ice-ufrag / ice-pwd +2 ms forward over WebSocket +8 ms re-gather locally +14 ms answer: new ufrag / pwd +22 ms forward answer +30 ms STUN checks on fresh candidate pairs +200–800 ms re-nomination — DTLS/SRTP untouched media flows
The restart exchange: two SDP hops costing tens of milliseconds, then 200–800 ms of connectivity checking before re-nomination.

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.

What an ICE restart replaces and what it preserves Left column lists state replaced by a restart: ICE credentials, the gathered candidate list, the candidate pair checklist, the nominated pair and TURN allocation, and the consent timers. Right column lists state preserved: the DTLS association and keys, the SRTP context and rollover counter, SSRCs and payload types, m-line order and transceivers, and SCTP data channel state. One RTCPeerConnection, one session, two very different halves Replaced by the restart Preserved across the restart ice-ufrag and ice-pwd gathered candidate list candidate pair checklist nominated pair + TURN allocation STUN consent timers DTLS association and keys SRTP context and rollover counter SSRCs and payload types m-line order and transceivers SCTP data channel state the fingerprint stays constant — that is what keeps the right column intact
An ICE restart swaps the transport underneath a session that never notices; only the left column is rebuilt.

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:

Edge Cases & Browser Quirks

Common Implementation Mistakes

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.