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.
“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.
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.
Reproduction Steps & Debugging Log Patterns
- Bring up a normal call and record a baseline from one
getStats()snapshot: the nominatedcandidate-pairid,transport.selectedCandidatePairChanges, andinbound-rtp.framesDecoded. - 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. - Poll
getStats()at 1 s while it runs and logframesDecodeddeltas. A correct implementation never shows a zero delta. - 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.
- Confirm in
chrome://webrtc-internalsthat the SDP history holds two offers with differenta=ice-ufragand one identicala=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.
Common Implementation Mistakes
- Bundling track surgery into the restart. Calling
replaceTrack(), re-adding a transceiver, or recreating a sender in the same function throws away the encoder state and, worse, the pair that was still carrying media. Session-level changes belong in their own exchange, as SDP Renegotiation Without Dropping Streams sets out. - Resetting the attempt counter on the first
connected. An oscillating link reachesconnectedbetween every flap, so the budget refills and the cap never binds. Require a continuous stable window — 10 s works — before clearing the ledger. - Using
createOffer({ iceRestart: true })as the default path. It throws fromhave-remote-offerand it bypasses yournegotiationneededglare handling entirely. Feature-detectrestartIce()and treat the manual offer as the exception. - Both peers restarting on the same event. The two restarts collide, one rolls back, and the rolled-back side often fires a duplicate a few hundred milliseconds later — three attempts gone in under a second, on a network that only ever needed one.
- Narrowing
iceTransportPolicyto'relay'while arming the restart. It looks like a reasonable escalation, but it invalidates the host and reflexive pair currently in use, so the overlap window collapses and you get a guaranteed gap plus a slower relay-only gathering pass.
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.