DEV Community

AiLaoHuYu
AiLaoHuYu

Posted on

A frozen video tile and a working ICE state: the three-tier recovery ladder

The support call starts the same way every time: "it says connected but the picture is stuck." pc.iceConnectionState is connected. The signaling socket is open. Nothing in the logs is an error.

That is the failure this article is about, because the fix is not a config value. It is a recovery ladder: three tiers of escalating repair, each with a different cost, and a detection method that does not trust the connection state at all.

The whole thing in one line: connected describes the network path, not the picture, so a frozen tile and a healthy ICE state are entirely compatible.

Tier 0: detect on frames, never on ICE state

ICE state tells you whether a candidate pair was selected. It does not tell you whether frames are still arriving. A path that stops carrying media can sit in connected indefinitely, and a UI that renders a status dot from ICE state will happily show green over a black rectangle.

Ask the video element instead. requestVideoFrameCallback fires once per delivered frame, which makes it the cheapest possible liveness probe — no decoding, no stats parsing, and it is exactly the thing that stops happening when the picture freezes:

if (video.requestVideoFrameCallback) {
  video.requestVideoFrameCallback(function tick(now) {
    lastFrame = now;
    video.requestVideoFrameCallback(tick);
  });
}

setInterval(() => {
  const frozen = video.srcObject && performance.now() - lastFrame > 2000;
  // surface it; do not silently wait
}, 1000);
Enter fullscreen mode Exit fullscreen mode

Two seconds is a deliberate choice: long enough that a normal keyframe interval or a momentary loss spike does not trip it, short enough that a user notices the recovery instead of giving up.

This is also the reason getStats() is a second opinion rather than the primary signal. It is the right tool for the cause — framesReceived climbing while framesDecoded stays flat means codec or keyframe, a climbing packetsLost means bandwidth — but polling it for liveness costs more and reports later than a frame callback.

Tier 1: an ICE restart, with a deadline

A restart is the cheapest repair, because it re-gathers candidates without tearing down the PeerConnection, the tracks, or the application state:

function tryRestart() {
  if (restarting) return;
  restarting = true;
  sig.send({ type: 'restart' });
  setTimeout(() => {
    restarting = false;
    if (pc && pc.iceConnectionState === 'failed') fullRejoin();
  }, 5000);
}
Enter fullscreen mode Exit fullscreen mode

Two details in those nine lines are the whole lesson.

The restarting flag is what keeps a restart from stacking on top of itself while a previous attempt is still in flight. Without it, a 5-second timer that fires every second produces a restart storm, and the storm is worse than the original fault because now the server is doing new offers for a client that is still falling over.

The 5-second deadline is what stops the ladder from looping forever at tier 1. A restart fixes a path that changed — a phone that moved from Wi-Fi to cellular, a NAT mapping that expired, a route that shifted. It cannot fix a remote peer that is gone, and if you do not escalate, a client whose publisher has disappeared will retry restarts until the tab is closed.

Tier 2: the full rejoin, and why the old PeerConnection must die

When the server rebuilds its side — the publisher went away, or republished as a new session — the next offer is not a continuation of the session you were in. It is a new one. Reusing the old PeerConnection at that point produces the worst possible outcome: a connection that negotiates successfully, reports connected, and carries nothing, because the transceivers and the media state belong to a session the server has already discarded.

So the second tier is an explicit teardown, then a fresh socket:

function teardown() {
  if (pc) { pc.close(); pc = null; }
  video.srcObject = null;
}

function fullRejoin() {
  teardown();
  sig.reconnect();
}
Enter fullscreen mode Exit fullscreen mode

Closing the PeerConnection is not cosmetic. It releases ICE agents and transceivers, and it makes the next createOffer a genuinely new negotiation instead of a delta against stale state.

The signaling layer needs two guards of its own

The ladder above is client-side recovery, but it depends on the socket underneath it behaving. Two guards matter, and both are absent from naive reconnect logic.

An epoch counter. A socket that closes after a newer socket has already connected would, without a guard, trigger a second reconnect and you end up with two live sockets and duplicated messages. Increment an epoch on every connect and let the handlers from stale sockets return early:

connect() {
  this.epoch += 1;
  const myEpoch = this.epoch;
  const ws = new WebSocket(this.url);
  ws.onclose = () => {
    if (myEpoch !== this.epoch) return;   // a newer socket already owns the state
    if (!this.closedByUser) this.retry();
  };
}
Enter fullscreen mode Exit fullscreen mode

A send queue for the connecting window. The self-test path builds its offer during page load, before the socket has necessarily finished opening. A send() that returns false in that window silently drops the offer, and the symptom on the other side is "the server never answered" — a client bug that looks exactly like a server bug. Queue while readyState === CONNECTING and flush on open.

The retry itself is exponential with a ceiling, min(500 * 2^n, 8000), because a reconnecting client that hammers a server which is already struggling is a self-inflicted second outage.

What not to do

Do not keep the session alive with a client-side heartbeat timer. Hidden tabs throttle timers, and the tab is hidden precisely when a technician has put the phone in a pocket. Keepalives belong on the server, over the socket, for the reasons in the earlier article on reverse proxies.

Do not treat a restart as free and fire it on every hint of trouble. A restart re-gathers candidates and produces a new offer/answer; doing that on a loss spike turns a mild network blip into a visible glitch.

And do not hide the ladder from the user. A frozen tile with no indication is what makes someone close the tab. "Reconnecting" for two seconds, followed by a picture, is a product that looks like it is working.

The ladder, in order

  1. Detect on frames, not on iceConnectionState. A 2-second frame gap is a freeze.
  2. Try an ICE restart first, guarded by an in-flight flag.
  3. Give the restart a hard deadline. If it is still failed, escalate.
  4. Escalate to a full rejoin: close the PeerConnection, then reconnect fresh. A rebuilt server session needs a new negotiation.
  5. Guard the socket with an epoch counter and queue sends during CONNECTING.
  6. Back off exponentially to a ceiling, and keep keepalives on the server.

I built this ladder into the browser clients of a self-hosted WebRTC kit, together with the diagnostics panel that shows the selected candidate path, the freeze counter and the per-leg stats, so each tier can be watched failing and recovering on a real network instead of being taken on faith:

https://lowlatencykit.com/

The remote field-assist write-up, which walks the four network failure layers this ladder is designed to survive, is here:

https://lowlatencykit.com/guides/remote-field-assist-mobile-webrtc/

Top comments (0)