DEV Community

AiLaoHuYu
AiLaoHuYu

Posted on

Your WebRTC viewer says connected. The picture is frozen.

There is a failure mode in WebRTC that looks healthy from almost every angle:

pc.iceConnectionState === "connected";
pc.connectionState === "connected";
video.readyState === HTMLMediaElement.HAVE_ENOUGH_DATA;
Enter fullscreen mode Exit fullscreen mode

The signaling socket is open. The peer connection has a candidate pair. The video
element is attached. And the frame on screen has not moved for three minutes.

That last sentence is the problem. connected describes the transport, not the
picture. A media track can be live while no new frames are rendered.

Try the freeze-and-recovery demo in your browser. It freezes a real media stream, shows Transport: connected throughout the outage, then walks through ICE restart and full rejoin. No install and no account.

Frames are the health check

If the audience is watching video, the useful health signal is not a connection state.
It is an advancing frame.

requestVideoFrameCallback gives us that signal. It fires when the browser has a new
video frame to present, and its metadata includes the presentation time:

video.requestVideoFrameCallback((now, metadata) => {
  console.log(metadata.presentationTime, metadata.width, metadata.height);
});
Enter fullscreen mode Exit fullscreen mode

That one callback can drive both recovery and observability: the viewer is healthy when
frames are arriving, and it is frozen when they stop.

Older browsers may not implement the callback. There, a timer can watch
video.currentTime. It is less precise, but it still distinguishes playback from a
still image.

Recover in the cheapest order

When frames stop, the instinct is to tear down and reconnect everything. That works,
but it is the most expensive option: a new socket, a new offer, a new answer and a new
peer connection. Most interruptions do not require all of that.

I use a three-step recovery ladder:

  1. ICE restart. Reuse the current session and negotiate new candidates. This is usually enough after a Wi-Fi change or a network path failure.
  2. Full rejoin. Replace the signaling session and peer connection. This covers server-side sessions that went stale or a publisher that republished.
  3. Backoff and retry. Repeating full rejoins without a delay can turn one broken client into a retry storm.

The deadlines matter. A restart that has not produced a new frame in five seconds is
not a successful restart. A rejoin that has not produced a new frame in eight seconds
is not a successful rejoin. The transition should be driven by rendered frames, not by
the fact that a connection event fired.

A compact implementation

I published the recovery logic as webrtc-recovery. It contains no signaling or media
server code; you provide restart() and rejoin() for your own stack.

import { createRecoveryLadder, watchFrames } from "webrtc-recovery";

const ladder = createRecoveryLadder({
  restart: () => signaling.send({ type: "restart" }),
  rejoin: () => {
    pc.close();
    pc = null;
    signaling.reconnect();
  },
});

watchFrames(video, {
  onStall: () => ladder.onIceState("failed"),
  onResume: () => ladder.markHealthy(),
});

video.requestVideoFrameCallback(function tick() {
  ladder.markHealthy("frame");
  video.requestVideoFrameCallback(tick);
});
Enter fullscreen mode Exit fullscreen mode

The important part is the invariant: only a rendered frame clears the recovery
deadline. A successful setRemoteDescription or an iceConnectionState transition is
not evidence that somebody can see the stream.

The package is ESM-only, has zero runtime dependencies, and the state machine accepts
an injected clock, so the timeout branches can be tested without real waits. The
current release is available from GitHub while the npm listing is being prepared:

npm install https://github.com/AiLaoHuYu/webrtc-recovery/archive/refs/tags/v0.1.1.tar.gz
Enter fullscreen mode Exit fullscreen mode

Repository:
https://github.com/AiLaoHuYu/webrtc-recovery

Diagnose one thing, not ten numbers

getStats() can return dozens of numbers at once. A viewer does not need a dashboard;
it needs the first useful explanation.

Check the highest-severity condition first:

  1. Is the transport failed or closed?
  2. Is the frame watchdog reporting a stall?
  3. Is packet loss already bad enough to explain poor quality?
  4. Is the jitter buffer growing?
  5. Is the sender capped by bandwidth or CPU?
  6. Is signaling up but no frame dimensions arriving?

That ordering avoids the common mistake of telling a user about 3% packet loss while
the real problem is that the peer connection is dead.

The same package exposes sampleConnection() to flatten a stats report and
diagnose() to turn the first actionable condition into one sentence.

What this does not solve

Client recovery cannot fix a server that is down, a publisher that stopped sending
media, or a TURN service with no capacity. It also cannot make an expired signaling
session valid.

What it can do is stop a viewer from sitting on a frozen frame while every status badge
says everything is fine. In a live video product, that difference is the whole user
experience.

If you are assembling the rest of the stack yourself, the library and source are here:

https://github.com/AiLaoHuYu/webrtc-recovery

If you would rather start from the full self-hosted WebRTC video server, including
signaling, the SFU, the publisher and the viewer, see https://lowlatencykit.com.
The complete source package is also available through
Codester.

Top comments (0)