Why WebRTC video freezes, and how getStats tells you
WebRTC video freezes come from loss, keyframe waits, jitter or dropped frames. The getStats counters that tell them apart, and the fix for each.
The picture stops. Two seconds later it snaps back, usually to a moment that is not where it left off, and somebody says “you froze”. Then the ticket arrives saying “video freezes sometimes” and you go looking at bitrate graphs.
Four different failures produce that same stop in a WebRTC call: packet loss that repair could not fix, a wait for a keyframe, jitter, and frames the decoder dropped. The receiving browser counts all four separately, and the numbers are already in getStats(). Nobody reads them because a freeze feels like a vague thing, and the counters look like a wall of names.
How WebRTC defines a freeze
It is defined. From the statistics spec, freezeCount:
It is a freeze if frame duration, which is time interval between two consecutively rendered frames, is equal or exceeds Max(3 * avg_frame_duration_ms, avg_frame_duration_ms + 150)
At 30fps the average frame is 33ms, so the threshold is 183ms, and the additive term wins. At 10fps it is 300ms and the multiplier wins. Worth knowing, because the same 200ms gap counts as a freeze on a camera feed and does not count on a slideshow-rate screen share.
pauseCount is the other one, and it is not a longer freeze. Video is paused when nothing has been rendered for five seconds. If that counter moves, stop reading this article and go look at ICE and the DTLS transport, because a codec problem does not last five seconds.
Both are cumulative, along with everything else here, so a single getStats() call tells you almost nothing. You need two and a subtraction.
NACK, RTX and PLI: how loss gets repaired
A receiver that spots a gap in sequence numbers sends a NACK naming the first missing packet plus a 16-bit mask covering the next 16, so one message asks for at most 17 packets. The sender replies over RTX, on its own SSRC, with the original sequence number tucked inside the payload.
All of that is a race against a deadline. The retransmitted packet has to arrive before the jitter buffer hands that frame to the decoder, which costs one round trip. If your buffer holds 60ms of media and your round trip is 90ms, NACK will never work, and sending more of them will not change that. This is the part people miss when they turn retransmission on and watch nothing improve.
When the repair misses, the reference chain is broken and every later frame decodes to garbage. The receiver sends a PLI, which carries no information beyond “I cannot decode, start over”, and waits for a keyframe.
It should not send a FIR. RFC 5104 is blunt about it:
Using the FIR command to recover from errors is explicitly disallowed, and instead the PLI message defined in AVPF should be used.
So firCount climbing during a loss event means something in your stack is sending the wrong message. That is usually a gateway or an older server, not the browser.
Diagnose the freeze from getStats counters
Poll once a second and diff:
let prev = null;
setInterval(async () => {
for (const s of await pc.getStats()) {
if (s.type !== 'inbound-rtp' || s.kind !== 'video') continue;
if (prev) {
const dt = (s.timestamp - prev.timestamp) / 1000;
const frames = s.jitterBufferEmittedCount - prev.jitterBufferEmittedCount;
console.log({
fps: (s.framesDecoded - prev.framesDecoded) / dt,
dropped: s.framesDropped - prev.framesDropped,
nack: s.nackCount - prev.nackCount,
pli: s.pliCount - prev.pliCount,
keyframes: s.keyFramesDecoded - prev.keyFramesDecoded,
freezes: s.freezeCount - prev.freezeCount,
bufferMs: frames ? ((s.jitterBufferDelay - prev.jitterBufferDelay) / frames) * 1000 : 0,
});
}
prev = s;
}
}, 1000);
That last line is the one worth adding to your dashboard. jitterBufferDelay is a sum of seconds and jitterBufferEmittedCount is a count of frames, so the ratio of the two deltas is the average time a frame spent waiting, in that second.
Now read the combinations.
nackCount rising, framesDecoded steady, freezeCount flat. Repair is working. This is what a healthy lossy link looks like and there is nothing to fix. Check what the retransmissions cost you in bytes and move on.
pliCount and keyFramesDecoded rising together, one freeze each time. Loss is beating the buffer deadline. The freeze you see is a round trip plus however long the encoder took to produce a keyframe plus however long that keyframe took to arrive, and it is large.
pliCount rising, keyFramesDecoded flat. You are asking and not receiving. Either a server is throttling the request, which is the next section, or the publisher’s encoder is pinned.
framesDropped rising while framesDecoded looks fine. The network did its job. The spec counts a frame here when it is dropped before decode or because it “missed its display deadline”, and a decoder that is keeping up with a renderer that is not means CPU, a background tab, or a machine that is thermally done. Lowering the bitrate will not help and people try it anyway.
freezeCount rising with pliCount and nackCount flat, and bufferMs growing. Nothing is lost. Frames are arriving late and in clumps. That is jitter, and it wants a different fix from loss.
Keyframe requests and PLI throttling on the SFU
A keyframe is many times the size of a delta frame. mediasoup measured the effect of viewers asking a broadcaster for them and put it plainly: the sending bitrate “would increase by 2x or 3x”. On a link that was already dropping packets, that spike causes more loss, which causes more PLIs.
Every serious forwarding server therefore refuses to pass them all through. mediasoup will not let a producer receive more than one PLI or FIR per second. LiveKit exposes it as pli_throttle and ships defaults of 500ms for the low layer and one second for the middle and high ones.
Read those numbers as a floor on your freeze duration. If the server will only ask once per second, then some of your freezes are a second long before the encoder has done any work at all, and no amount of client tuning shortens them. Raising the throttle makes the individual freezes shorter and the storm worse. This is a real tradeoff with no clever answer, which is why every SFU makes you configure it.
Freezes from simulcast layer switches
Simulcast layer switching produces freezes with no packet loss anywhere. The SFU cannot start forwarding a layer mid-stream, because the decoder has no reference frames for it, so a switch means asking the publisher for a keyframe on the new layer and forwarding nothing until it comes. mediasoup lists the causes of a keyframe request together: a viewer connects or reconnects, changes its preferred spatial layer, or loses too many packets.
The confusing part is the correlation. Bandwidth improves, the server moves you up a layer, and the picture freezes. Users report that the video breaks when the network gets better, which sounds like nonsense and is exactly right.
If your freezes cluster around layer changes rather than loss spikes, stop tuning retransmission. Add hysteresis to the switching logic, so a brief improvement in the estimate does not cost a keyframe.
Buffer more with jitterBufferTarget
RTCRtpReceiver.jitterBufferTarget takes milliseconds, up to 4000, and influences how much the browser buffers before playout. Firefox shipped it in 115 and Chromium in 123, after years of the same idea living there under the non-standard name playoutDelayHint. Safari has not.
receiver.jitterBufferTarget = 300;
Buffering more buys time for retransmissions to land, which turns “NACK loses to a 90ms round trip” into “NACK wins comfortably”, and takes PLIs and their keyframes off the table. You pay in latency, so for a conversation the answer is usually no. For anything one-way, a talk, a screen share of a terminal, a broadcast, 300ms is invisible to the viewer and buys a lot.
To see how much of your delay you actually chose, compare jitterBufferTargetDelay against jitterBufferMinimumDelay, which the spec defines as the minimum achievable “based on network characteristics only”. The gap between them is your hint.
Check the counters before lowering the bitrate
Reach for the counters before the encoder settings. The instinct when someone reports freezing is to lower the bitrate and the frame rate, and often the numbers were already saying the frames arrived intact and got thrown away at the display deadline, or that a layer switch asked for a keyframe that nobody sent. Both of those get worse when you turn the quality down, because the freeze was never about how much data was on the wire.