Triggering an ICE Restart Without Dropping Media

An ICE restart does not, by itself, interrupt anything. The freeze users complain about comes from the state the connection was already in when the restart fired, and from the three or four “helpful” extra calls developers wrap around it. This guide is part of the Connection Recovery & ICE Restart guide, and it covers exactly one thing: the call sequence that keeps RTP flowing on the currently nominated pair for the whole duration of the restart, who is allowed to issue it when both peers can offer, and how to stop the retry loop at three.

Context & Trade-offs

RFC 8445 §9 is unusually generous here. Restarting ICE rotates ice-ufrag/ice-pwd and builds a brand-new checklist, but the agent is told to keep sending media over the previously selected pair until the new session nominates a replacement. Two candidate pairs therefore coexist for the length of the exchange: the old one carrying RTP and answering consent checks, the new one running connectivity checks under fresh credentials. The handover is a pointer swap at the end, not a teardown at the start.

That overlap is only available if the old pair is still functional. This is the whole game, and it reframes the question from “how do I restart faster” to “how early can I justify restarting”:

Trigger point Old pair still usable? Media gap observed Attempts spent
Deliberate path upgrade on a healthy call yes 0 ms — framesDecoded never flatlines 1
Interface change signalled by the OS usually, for 1–3 s 0–150 ms 1
2–3 s grace after disconnected often (one-way blackhole) 0–800 ms 1
After connectionState reaches failed no 9–16 s of dead air, plus 200–800 ms 1–3

The bottom row is not a slow restart; it is a fast restart preceded by a long wait for consent to expire, and the split between those two states is worked through in the engine timings elsewhere in this section. The 200–800 ms figure is the connectivity-check window itself, and it assumes trickle is on — a restart whose answer arrives as one bulk SDP loses the head start described in ICE Candidate Trickle vs Bulk Gathering. Add 20–40 ms one-way if the winning pair is a relay.

Overlap window: restarting early versus restarting after failure Scenario A fires the restart while the old candidate pair still answers, so the old pair carries RTP while the new checklist runs and the handover costs no measurable gap. Scenario B waits until the state reaches failed, so the old pair stopped carrying media long before the checklist starts and the gap spans the whole consent expiry plus the checking window. Same restart, two trigger points — the gap is decided by when you fire it A — fired while the old pair still answers consent media old pair carries RTP new pair carries RTP checks new checklist re-nomination — 0 ms gap B — fired only after the state reached failed media old pair dead air — nothing to fall back to new pair checks new checklist consent expiry burned the first 9–16 s 0 s 5 s 10 s 15 s 20 s
The old pair is a free safety net, but only while it is still alive — waiting for failed throws it away before the restart begins.

“Still usable” is narrower than “not yet failed”. A one-way blackhole — the common CGNAT rebinding case, where your packets still reach the peer but theirs no longer reach you — leaves the pair perfectly capable of carrying outbound RTP while inbound has already stopped, so the overlap saves the sending direction even when the receiving direction is already dark. A dropped interface saves nothing, because the socket the pair is bound to no longer exists. Knowing which of the two you are in decides whether the restart is a seamless swap or damage control, and it is the reason a byte-delta detector is worth more than a state listener.

The cost side is real but small at the signalling layer: two SDP hops, each delivered in under 10 ms over a WebSocket. The expensive parts are re-gathering against every configured server and holding a second TURN allocation until the old one ages out, which is why the attempt budget exists at all.

Minimal Runnable Implementation

Order matters more than any individual call. Check the budget first, because it is the only step that can decline without side effects. Then touch nothing except the ICE layer — no sender.replaceTrack(), no transceiver.stop(), no narrowing of iceTransportPolicy — since each of those invalidates the pair that is currently carrying RTP and manufactures the gap you are trying to avoid. Only then arm the restart.

Prefer pc.restartIce(). It sets a flag rather than producing SDP, so it is legal from any signalingState: if you are mid-renegotiation it simply defers, and negotiationneeded fires once the connection is back to stable. pc.createOffer({ iceRestart: true }) builds the offer immediately, which is what you want when you drive negotiation by hand or when restartIce() is absent — WebKit only shipped it in Safari 15.4, and older iOS WKWebView hosts covered in Debugging WebRTC on Safari and iOS WKWebView still need the fallback. The fallback carries a precondition the flag does not: call it from have-remote-offer and it throws InvalidStateError, wasting an attempt.

// ---- attempt ledger: 3 restarts per sliding minute, refilled only by real stability ----
const budget = { stamps: [], WINDOW_MS: 60_000, MAX: 3, pending: false };

function mayRestart() {
  const now = performance.now();
  budget.stamps = budget.stamps.filter(t => now - t < budget.WINDOW_MS); // expire the window
  return budget.stamps.length < budget.MAX;
}

// Ten seconds of uninterrupted 'connected' clears the ledger. Clearing it on the first
// 'connected' event lets a flapping link restart forever, three attempts at a time.
let stableTimer = null;
pc.addEventListener('connectionstatechange', () => {
  clearTimeout(stableTimer);
  if (pc.connectionState !== 'connected') return;
  stableTimer = setTimeout(() => { budget.stamps.length = 0; budget.pending = false; }, 10_000);
});

async function triggerIceRestart(reason) {
  // Cheapest guard first: decline before touching the peer connection at all.
  if (budget.pending || !mayRestart()) return false;
  budget.stamps.push(performance.now());
  budget.pending = true;
  console.info(`[ice] restart ${budget.stamps.length}/${budget.MAX} reason=${reason}`);

  // Nothing below may disturb senders, transceivers, or the transport policy: the old
  // nominated pair has to keep forwarding RTP until the new checklist wins.
  if (typeof pc.restartIce === 'function') {
    pc.restartIce();          // flag only — safe from any signalingState, defers if not stable
    return true;              // negotiationneeded does the rest
  }

  // Fallback for engines without restartIce(): the offer is built here and now, so the
  // connection must already be stable or createOffer rejects with InvalidStateError.
  if (pc.signalingState !== 'stable') { budget.pending = false; return false; }
  const offer = await pc.createOffer({ iceRestart: true }); // rotates ufrag/pwd, nothing else
  await pc.setLocalDescription(offer);
  signaling.send({ type: 'description', sdp: pc.localDescription });
  return true;
}

One consequence of getting this right catches people out: on a successful zero-gap restart, connectionState never leaves connected. Nothing failed, so nothing transitions, and a completion detector built on connectionstatechange will wait forever and then time out on a restart that actually worked. The signal that a restart landed is transport.selectedCandidatePairChanges incrementing, with iceGatheringState returning to complete as the corroborating event. Arm the per-attempt 4 s timeout against that counter, not against a state you may never see change.

Between the flag and the nomination the connection passes through a short-lived pair of ICE generations. Tracking that explicitly — rather than treating the restart as an atomic event — is what makes the timeout and retry logic honest, because the only thing that ends generation N is the successful nomination of a pair in generation N+1.

ICE generation state machine across one restart Generation N is live and nominated. Calling restartIce arms a restart, which produces a local offer with new credentials, then a checking phase for generation N plus one during which generation N still forwards media. When a pair succeeds the new generation is nominated and the old one is pruned. A four second timeout re-arms the restart while attempts remain, and sends the connection to a rebuild once the budget is spent. gen N nominated restart armed have-local-offer checking gen N+1 gen N+1 nominated gen N pruned re-arm, attempt < 3 rebuild connection restartIce() new ufrag answer applied pair wins RTP moves no pair in 4 s 3 used re-arm generation N keeps forwarding media everywhere along the blue path
Two generations coexist from "restart armed" until a pair wins; only nomination retires the old one.

Picking the restarter in a glare-prone topology

If both peers can offer, both can also restart, and simultaneous restart offers collide exactly like any other pair of offers. Perfect negotiation resolves it the usual way — the impolite peer ignores the incoming offer, the polite peer rolls back and answers — with one wrinkle specific to restarts. Rollback discards the polite peer’s pending restart flag, so a naive implementation re-arms and fires a second, entirely redundant restart a few hundred milliseconds later. It does not need to: an answer to a restart offer must itself carry fresh ice-ufrag/ice-pwd, so the polite peer’s credentials rotate anyway. Clear your own intent when you answer, and the general collision rules in Recovering from Glare in Offer Collisions handle the rest.

// Perfect negotiation, with the restart-specific bookkeeping folded in.
let makingOffer = false;

pc.onnegotiationneeded = async () => {
  makingOffer = true;
  try {
    await pc.setLocalDescription();  // implicit offer; carries the restart flag if armed
    signaling.send({ type: 'description', sdp: pc.localDescription });
  } finally { makingOffer = false; }
};

signaling.on('description', async ({ sdp }) => {
  const collision = sdp.type === 'offer' && (makingOffer || pc.signalingState !== 'stable');
  if (collision && !polite) return;  // impolite peer keeps its own offer and ignores theirs
  await pc.setRemoteDescription(sdp);
  if (sdp.type !== 'offer') return;
  await pc.setLocalDescription();    // the answer inherits fresh credentials automatically
  signaling.send({ type: 'description', sdp: pc.localDescription });
  // Rollback ate our restart flag, but answering a restart offer already rotated us.
  // Drop the intent instead of re-arming, or you fire a duplicate restart in ~300 ms.
  if (collision) budget.pending = false;
});

Topology decides how much of that machinery you need at all. A client talking to a media server has no glare surface if the server is configured to answer only; a symmetric peer-to-peer call has the maximum surface and needs a deterministic tie-break assigned once, at join time, rather than negotiated in the moment. Derive the role from something both sides already agree on and neither can change mid-call — the lexicographically lower participant id, or a boolean the signalling server stamps into the join response — and never from something observable like “who noticed first”, which is precisely the value that will be identical on both sides during a shared network event. The peer that loses the tie-break is not passive: it still detects the degradation, and it sends a restart-requested message so the offering side acts within a signalling round trip instead of waiting for its own detector to catch up.

Who restarts, by topology Three rows. Symmetric peer to peer allows either side to offer, carries high glare risk, and is resolved by the impolite peer restarting. Client to media server allows the client only, carries no glare risk because the server always answers. Client to legacy gateway allows the gateway only, carries medium risk, and is resolved by requesting the restart over the signaling channel. Assign the restarter role at join time, not during the outage Topology May offer Glare risk Deciding rule symmetric peer-to-peer either side high impolite peer restarts client to media server client only none server answers only client to legacy gateway gateway only medium ask over signaling a peer that may not offer still detects the failure — it requests, it does not restart
Only one side should ever hold the restart role; the other detects and asks.

Reproduction Steps & Debugging Log Patterns

  1. Bring up a normal call and record a baseline from one getStats() snapshot: the nominated candidate-pair id, transport.selectedCandidatePairChanges, and inbound-rtp.framesDecoded.
  2. On the healthy call, run pc.restartIce() from the console. This is the cleanest reproduction of the overlap window, because nothing is broken — any gap you observe is caused by your own code, not the network.
  3. Poll getStats() at 1 s while it runs and log framesDecoded deltas. A correct implementation never shows a zero delta.
  4. Repeat with the path genuinely broken (drop the interface, or blackhole the remote address) and compare: the same code now shows a gap whose length is dominated by detection, not by checking.
  5. Confirm in chrome://webrtc-internals that the SDP history holds two offers with different a=ice-ufrag and one identical a=fingerprint; reading those panes is covered in Reading chrome://webrtc-internals Dumps.

A healthy restart on a live call logs like this:

// t+0 ms    [ice] restart 1/3 reason=manual-probe
// t+3 ms    negotiationneeded → setLocalDescription  ufrag 4bQ1 → 9Kt2
// t+11 ms   answer applied     remote ufrag Lm7x (changed, good)
// t+1000 ms framesDecoded 4820 → 4850   pairChanges 1   // still the OLD pair
// t+2000 ms framesDecoded 4850 → 4880   pairChanges 1   // checks running underneath
// t+2310 ms pairChanges 1 → 2           srtpCipher unchanged
// t+3000 ms framesDecoded 4880 → 4910   // no flatline anywhere in the sequence

The failure signature to look for is a framesDecoded delta of exactly zero on the sample before pairChanges increments. That means something in your restart path tore down the old pair early — and the usual culprit is a track or transceiver operation bundled into the same code path, not the restart itself.

Anatomy of a restart offer A panel of SDP lines from a restart offer. The ice-ufrag and ice-pwd lines and the session version rotate, along with a fresh candidate generation. The fingerprint, mid, ssrc and payload type lines are unchanged, which is what keeps DTLS and SRTP alive across the restart. A restart offer is an ordinary offer with exactly three things rotated v=0 o=- 4611 3 IN IP4 127.0.0.1 m=audio 9 UDP/TLS/RTP/SAVPF 111 a=ice-ufrag:9Kt2 a=ice-pwd:kJ8sQ1vR7wZp a=fingerprint:sha-256 8F:2A:C1 a=mid:0 a=ssrc:1178402 cname:s1 rotates on every restart ice-ufrag and ice-pwd o= session version candidate generation must be byte-identical a=fingerprint a=mid and m-line order a=ssrc and payload types a=setup role
Diff your restart offer against the previous one: three purple lines should change and nothing green should move.

Common Implementation Mistakes

FAQ

Is it safe to restart ICE on a connection that is working fine?

Yes, and it is the only way to get a provably zero-gap handover — the old pair keeps carrying RTP until the replacement is nominated. Treat it as a real cost though: it re-gathers against every STUN and TURN server and holds a second relay allocation until the first ages out, so it should draw from the same 3-attempt budget as a recovery restart.

Does the answering peer have to call restartIce() as well?

No. Applying a remote description whose ice-ufrag and ice-pwd differ from the current ones obliges the answerer’s stack to generate its own new credentials in the answer. Calling restartIce() there too only queues a second, pointless exchange.

My restart works but the gap is still around 1.5 s — where is it coming from?

Almost never from the checking phase. Look at how quickly the remote answer arrives and whether candidates are trickling; a bulk answer, a slow TURN allocation, or a signalling hop that queues the offer behind other work all show up as checking-phase latency when they are really delivery latency. Timestamp the offer send, the answer apply, and the selectedCandidatePairChanges increment separately, and the guilty interval names itself.

Related: return to Connection Recovery & ICE Restart, then read Handling Wi-Fi to Cellular Network Handover for the interface-change trigger and Disconnected vs Failed ICE States for the detection timings that decide which row of the trade-off table you land on.