Disconnected vs Failed ICE Connection States
disconnected means the ICE agent has stopped hearing from a candidate pair it still believes in; failed means it has run out of pairs to believe in. This guide is part of the Connection Recovery & ICE Restart guide, and it settles one operational question: how long to wait before treating a state change as a real outage, and how to space the reaction so your recovery code does not become the outage. Telling the two states apart correctly is worth 10β20 seconds of dead air per incident on Chrome, and on Firefox it is the difference between an invisible blip and a user-visible renegotiation.
Context & Trade-offs
The specification and the implementations disagree in a way that matters. RFC 8445 describes disconnected as informational: the agent keeps its checklist alive, keeps retransmitting, and may return to connected without any application involvement. failed in the spec is the state after the checklist is exhausted, and while nothing formally forbids a later recovery, every shipping browser treats it as absorbing β the only exits are restartIce() or a new RTCPeerConnection. So disconnected is a request for patience and failed is a demand for action, and the correct code path for each is almost the opposite of the other.
There are also two distinct routes into failed that share one enum value. The first is exhaustion during checking: no pair ever worked, which nearly always means a configuration fault β an expired TURN credential, iceTransportPolicy: 'relay' with an unreachable relay, or UDP blocked with no TCP fallback. The second is consent revocation after a healthy connected: the path existed and died. Restarting ICE fixes the second reliably and the first almost never, because re-gathering against the same broken credential reproduces the same result three times in a row. Log which route you took before you decide.
The timers behind each transition come from consent freshness. Once a pair is nominated the agent sends STUN binding requests over it roughly every 5 seconds with randomisation, and RFC 7675 revokes consent after 30 seconds without a valid response. Chrome fires disconnected on the first unanswered exchange, so it appears 2β5 seconds after the break depending on where the break lands relative to the jitter, and invalidates the pair 15β20 seconds in. Firefox watches incoming media as well as consent and will flag disconnected within about a second of RTP going quiet, but it is far slower to give up, sitting closer to the 30-second ceiling before declaring failed. Safari 17 lands between the two. The practical consequence: a 1.5-second Wi-Fi hiccup produces no events at all on Chrome and a disconnected β connected pair on Firefox, from the same physical event.
| State | How it is entered | Typical dwell | Terminal? | Correct reaction |
|---|---|---|---|---|
disconnected (from connected) |
one or more consent checks unanswered, or RTP silence | 1β3 s on Firefox, 5β15 s on Chrome | no | wait out a 2β3 s grace window |
failed (from connected) |
every pair invalidated after consent revocation | permanent | yes | restart immediately, backoff between attempts |
failed (from checking) |
checklist exhausted, no pair ever nominated | permanent | yes | fix credentials or transport policy, do not spin restarts |
disconnected (mobile background) |
ICE activity suspended by the OS | until foreground | effectively | restart on visibilitychange, not on a timer |
One more subtlety decides which of those rows you are actually in: the state on RTCPeerConnection is an aggregate. With BUNDLE negotiated there is a single RTCIceTransport, so the aggregate and the transport agree. Without BUNDLE β an older gateway peer, or a client that stripped the attribute during munging β audio and video own separate transports, and the aggregate reports the worst of them. A video transport that hits failed while audio stays connected surfaces as a peer-level failed, and restarting ICE restarts both. That is usually what you want, but it means the aggregate alone cannot tell you which media stopped; walk pc.getTransceivers() and read each transceiver.sender.transport.iceTransport.state before you log a diagnosis, otherwise every non-bundled incident gets filed under the same symptom.
failed, and your application has to draw it.Over-reacting has a measurable price. An ICE restart mints a new ufrag/pwd pair, requires a full offer/answer exchange through your signalling channel, and re-runs gathering including TURN allocation; even with sub-10 ms signalling delivery the whole cycle costs roughly 300 ms on a host-to-host path and up to 3 seconds when a relay has to be re-allocated, and the newly relayed path carries 20β40 ms of extra one-way latency it may not have had before. Fire that at the first Firefox disconnected and you have converted a 1.5-second blip into a 3-second renegotiation, on every peer in the room simultaneously. Under-reacting is just as expensive in the other direction: waiting for Chrome to reach failed spends 15β20 seconds of frozen video before the recovery you were always going to perform. The 2β3 second grace window exists precisely because it is longer than the Firefox self-heal window and much shorter than the Chrome failure timer.
Minimal Runnable Implementation
The controller below encodes the whole policy: patience for disconnected, immediacy for failed, jittered exponential backoff between attempts, a hard cap of three, and cancellation the moment the connection comes back on its own.
const BACKOFF_MS = [0, 1000, 2000, 4000]; // delay applied before attempt n (1-indexed)
const GRACE_MS = 2500; // longer than a Firefox self-heal, shorter than Chrome's failed timer
const MAX_ATTEMPTS = 3;
const recovery = { attempts: 0, graceTimer: null, retryTimer: null, enteredAt: 0 };
function clearTimers() {
clearTimeout(recovery.graceTimer); // cancel a pending grace expiry
clearTimeout(recovery.retryTimer); // cancel a scheduled backoff attempt
recovery.graceTimer = recovery.retryTimer = null;
}
function scheduleRestart(reason) {
if (recovery.retryTimer) return; // one attempt in flight at a time
if (recovery.attempts >= MAX_ATTEMPTS) {
console.error('[ice] budget exhausted β tearing down and rebuilding', { reason });
return rebuildPeerConnection(); // fresh RTCPeerConnection, fresh credentials
}
const n = ++recovery.attempts;
// Full jitter: spread a room full of clients so an SFU flap does not produce a restart herd
const delay = Math.round(BACKOFF_MS[n] * (0.5 + Math.random() * 0.5));
console.warn(`[ice] restart ${n}/${MAX_ATTEMPTS} in ${delay} ms`, { reason });
recovery.retryTimer = setTimeout(async () => {
recovery.retryTimer = null;
pc.restartIce(); // marks the transports; onnegotiationneeded does the offer
}, delay);
}
pc.addEventListener('connectionstatechange', () => {
const state = pc.connectionState;
console.info('[ice]', state, `ice=${pc.iceConnectionState}`, `t=${Math.round(performance.now())}`);
if (state === 'connected') {
clearTimers();
recovery.attempts = 0; // only a clean connect resets the budget
return;
}
if (state === 'disconnected') {
recovery.enteredAt = performance.now();
// Patience: Firefox and mobile Chrome recover most of these unaided within 1β2 s
recovery.graceTimer ??= setTimeout(() => {
recovery.graceTimer = null;
if (pc.connectionState === 'disconnected') scheduleRestart('grace expired');
}, GRACE_MS);
return;
}
if (state === 'failed') {
clearTimeout(recovery.graceTimer); // no point waiting out a terminal state
recovery.graceTimer = null;
scheduleRestart('failed'); // Chrome never leaves this on its own
}
});
// A proven network change makes the old candidates stale, so skip the grace window entirely
addEventListener('online', () => {
if (pc.connectionState !== 'connected') {
clearTimers();
setTimeout(() => scheduleRestart('network change'), 300); // let the OS finish assigning an address
}
});
Two details in that code are easy to skip and expensive to omit. The jitter multiplier is not cosmetic: when a relay or an SFU node flaps, every client in the room observes the same event within milliseconds of each other, and an unjittered ladder sends all of them into TURN allocation at exactly the same instant, which is how a recoverable blip becomes a thundering restart herd against ports 49152β65535. And the attempt counter resets only on a genuine connected, never on entering disconnected, otherwise a connection that flaps between the two states will spend its three-attempt budget over and over and never escalate to the teardown that would actually fix it.
Reproduction Steps & Debugging Log Patterns
- Establish a call and record the nominated pair from a
getStats()poll on a 1 s interval β readcandidate-pairwherenominatedis true, and note its local and remote ports so you know exactly which 5-tuple to break. - Drop that 5-tuple at the OS firewall rather than disabling the interface. Killing the interface fires
online/offlineand triggers the network-change path; a silent drop is what you want, because it isolates the consent timer. - Log every
connectionstatechangeandiceconnectionstatechangewithperformance.now()deltas, and keep the stats poll running so you can seebytesReceivedflatline before the state moves. - Restore the rule after 2 seconds and confirm the connection self-heals with no restart. Then repeat with a 25-second break and confirm exactly three attempts fire before the rebuild.
- Run both variants in Firefox. The same script should show an earlier
disconnected, a self-heal inside the grace window on the short break, and a noticeably laterfailedon the long one.
A healthy short-break run β the case where doing nothing is correct β looks like this:
// t=0 firewall drop applied, bytesReceived stops advancing
// t=980 [ice] disconnected ice=disconnected t=980 // Firefox reacts to RTP silence
// t=1010 [ice] grace timer armed for 2500 ms
// t=2140 [ice] connected ice=connected t=2140 // consent answered, same pair re-validated
// t=2141 [ice] grace cancelled, attempts reset to 0 // zero restarts issued
The long-break run on Chrome shows the other shape: disconnected around 4β5 seconds, the grace window expiring, attempt 1 firing, then failed arriving mid-backoff while attempt 2 is still pending. That interleaving is normal β do not treat the arrival of failed as a reason to fire an extra restart when one is already scheduled, which is what the retryTimer guard prevents. For the raw evidence behind these numbers, the timing columns in Reading chrome://webrtc-internals Dumps show consent request/response pairs, and the ICE log in Diagnosing ICE Failures with Firefox about:webrtc prints each pairβs state transitions with timestamps.
Common Implementation Mistakes
- Branching on
iceConnectionStateinstead ofconnectionState. The legacy property is the more granular diagnostic, but it does not account for DTLS, so it can readconnectedwhile the transport is unusable. Branch policy onconnectionState; log both. - Resetting the attempt counter on
disconnected. A flapping path alternates between the two states, and a counter that resets on every dip never reaches the cap, so the client retries indefinitely and never rebuilds. - Arming a second grace timer on repeat
disconnectedevents. Browsers may emit the state more than once; use the??=guard shown above so one outage arms one timer and not a queue of overlapping ones. - Treating
failedfromcheckinglikefailedfromconnected. If no pair ever worked, three restarts reproduce the same failure. Check the TURN credential expiry and whether relay is even reachable before spending the budget β Forcing TURN over TCP 443 on Locked-Down Networks covers the usual culprit. - Restarting without jitter across a room. Synchronised restarts turn one SFU hiccup into a relay allocation stampede; full jitter on the backoff delay costs nothing and removes the correlation.
FAQ
Can failed ever go back to connected on its own?
Not on Chrome β since the state machines were aligned in Chrome 71 it is absorbing, and polling in hope only wastes time. Firefox has been observed recovering in rare cases where a pair revalidates late, but no production policy should depend on it. Treat failed as terminal on all engines and drive recovery yourself.
Should the grace window be the same on every browser?
A single 2β3 second window works everywhere and is what most deployments ship, because it clears Firefoxβs 1β2 second self-heal and still fires long before Chromeβs 15β20 second failure. If you do tune per engine, only shorten it on Chrome, where a disconnected that lasts past 3 seconds is unlikely to resolve unaided.
How does this interact with a network interface change?
It short-circuits the whole policy. When online fires or a new local address appears the old candidates are provably stale, so the grace window has no value β debounce 300 ms for the OS to finish configuring the interface, then restart immediately. That path is covered in Handling Wi-Fi to Cellular Network Handover.
Related: return to Connection Recovery & ICE Restart for the full recovery design, and see Triggering an ICE Restart Without Dropping Media for the renegotiation mechanics each attempt above depends on.