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.

ICE transport state machine with recoverable and terminal states Checking leads to connected and then completed. Connected drops to disconnected when a consent check goes unanswered, and disconnected returns to connected when a response arrives. Disconnected degrades to failed after roughly fifteen seconds, and checking can enter failed directly when the check list is exhausted. Failed only leaves through an explicit ICE restart, which returns the agent to checking with fresh candidates. restartIce() re-gathers with a new ufrag and re-enters checking checking connected completed disconnected failed restartIce() nominated checks done consent check unanswered response returns, pair re-validated ~15 s manual only check list exhausted β€” no pair ever worked healthy recovers on its own terminal until you intervene
Only one edge leaves 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
  }
});
Restart thrash versus a grace window with exponential backoff Both lanes cover the same twelve second path outage on a twenty second axis. The naive lane issues six ICE restarts between zero point three and twelve seconds. The policy lane waits two and a half seconds, then makes three attempts spaced by one point five, three point two and six seconds before tearing the peer connection down and rebuilding it. media path down, 0 to 12 s Naive: restart on every state event 6 offer/answer cycles, candidates re-gathered each time, media never settles Policy: 2.5 s grace, 3 attempts, jittered backoff grace 1 2 3 rebuild +1.5 s +3.2 s +6 s 3 attempts inside 13 s, then one clean teardown with fresh TURN credentials 0 s 4 s 8 s 12 s 16 s 20 s
Same 12-second outage, two policies: six restarts that fight each other, or three that give the network room to answer.

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

  1. Establish a call and record the nominated pair from a getStats() poll on a 1 s interval β€” read candidate-pair where nominated is true, and note its local and remote ports so you know exactly which 5-tuple to break.
  2. Drop that 5-tuple at the OS firewall rather than disabling the interface. Killing the interface fires online/offline and triggers the network-change path; a silent drop is what you want, because it isolates the consent timer.
  3. Log every connectionstatechange and iceconnectionstatechange with performance.now() deltas, and keep the stats poll running so you can see bytesReceived flatline before the state moves.
  4. 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.
  5. 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 later failed on 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.

Per-engine timing of the disconnected and failed transitions Chrome declares disconnected after about five seconds, rarely self-heals, declares failed after fifteen to twenty seconds and never recovers from it. Firefox declares disconnected within about a second, self-heals frequently in one to two seconds, and waits twenty-five to thirty seconds before failed. Safari sits between the two. Engine First disconnected Self-heals without help Declares failed Leaves failed unaided Chrome 120+ ~5 s, 1 missed check rarely 15–20 s never Firefox 115+ ~1 s, RTP silence often, in 1–2 s 25–30 s occasionally Safari 17 ~4 s sometimes ~30 s rarely Measured on a hard drop of the nominated 5-tuple; consent jitter moves each figure by a second or two.
One grace window has to sit above Firefox's self-heal time and well below Chrome's failure time β€” 2–3 s is the only band that satisfies both.

Common Implementation Mistakes

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.