DEV Community

AiLaoHuYu
AiLaoHuYu

Posted on

The six getStats numbers that actually tell you what broke

getStats() returns a few hundred fields across dozens of records, and almost all of them are useless for diagnosis. The problem is not that the data is missing. It is that the numbers which matter only mean something in combination, and a dashboard that prints all of them tells you nothing.

These are the six worth putting on a screen, in the order I would look at them.

The whole thing in one line: the selected candidate pair tells you the path, the inbound counters tell you whether media arrived, and the outbound reason tells you why the encoder is holding back.

1. The selected candidate pair, and the candidate types behind it

This is the one everyone gets wrong, because getStats() reports many candidate-pair records and only one of them is the path your media is actually on. Print the wrong pair and you will confidently announce a direct connection while the session is relayed, or the reverse:

stats.forEach((r) => {
  if (r.type === 'local-candidate' || r.type === 'remote-candidate') cands[r.id] = r;
  if (r.type === 'candidate-pair' && (r.selected || r.state === 'succeeded') && r.currentRoundTripTime != null) {
    pair = r;
  }
});
Enter fullscreen mode Exit fullscreen mode

Note the extra condition. Requiring currentRoundTripTime != null filters out pair records that exist but never carried a round trip — which is most of them on a NAT-heavy session. Without it, a "succeeded" pair with no measured RTT can win and you report a path that was never used.

The types are not on the pair. They live in separate records that the pair refers to by id, so you have to join them:

const l = cands[pair.localCandidateId];
const rm = cands[pair.remoteCandidateId];
out.localType = l ? l.candidateType : null;   // host | srflx | prflx | relay
out.remoteType = rm ? rm.candidateType : null;
Enter fullscreen mode Exit fullscreen mode

host → host means a direct path on the same network. relay → relay means both sides are going through TURN, which is the expected state behind a carrier NAT and the first thing to verify when someone says the relay is broken. Skipping the join is why people conclude "it is always relayed" or "it is never relayed".

2. Inbound bitrate, which requires two samples

bytesReceived is a cumulative counter. You cannot read a rate out of a single snapshot, which is why so many quick debugging scripts print a number that keeps growing and call it bitrate:

// Pass the previous sample in; the delta is the rate.
if (prev && prev.bytes != null && r.bytesReceived != null && now > prev.t) {
  out.bitrateKbps = Math.round(((r.bytesReceived - prev.bytes) * 8) / (now - prev.t));
}
out.bytes = r.bytesReceived;
Enter fullscreen mode Exit fullscreen mode

One unit trap lives in that line: (bytes delta * 8) / milliseconds is kilobits per second only because the division by a duration in milliseconds yields bits per millisecond — which is bits per second divided by a thousand. Get the units wrong here and your numbers are off by 1000, which looks like a codec problem.

3. framesPerSecond and resolution

These tell you whether the stream is alive at the quality you asked for. A stream that negotiated 1280×720 and settles at 320×180 is not broken; it is being told to back off, and the next two numbers say by what.

Read frameWidth/frameHeight from the inbound video record rather than from the video element's videoWidth. The element reports the decoded frame, which lags behind a change and is zero until the first frame decodes — the exact moment you most need a number.

4. qualityLimitationReason on the outbound side

This is the single most underused field in WebRTC diagnostics. When the encoder reduces resolution or frame rate, it says why:

if (r.type === 'outbound-rtp' && r.kind === 'video') {
  out.qlr = r.qualityLimitationReason || null;   // bandwidth | cpu | other | none
  ...
}
Enter fullscreen mode Exit fullscreen mode

bandwidth means the estimator decided the path cannot carry more, and the fix is the network or the bitrate ceiling. cpu means the sender could not encode fast enough, and the fix is on the machine — resolution, hardware encoding, or too many tabs doing the same thing. other is usually a screen-share capture path that cannot keep up. Those are three different investigations, and without this field you are guessing between them.

5. framesDecoded versus framesReceived

This pair is how you separate transport from decode. Packets arriving while decoded frames stay flat means the media reached you and could not be turned into pictures — codec parameters, a missing keyframe, or a hardware decoder that gave up.

out.freezeCount = r.freezeCount != null ? r.freezeCount : null;
out.framesDecoded = r.framesDecoded != null ? r.framesDecoded : null;
Enter fullscreen mode Exit fullscreen mode

freezeCount is the browser's own opinion that the picture stopped, which is a useful cross-check on your own frame-gap detection. If your 2-second frame-gap timer says "frozen" and freezeCount is still rising while framesDecoded is not, you are looking at decode, not at the network. If framesDecoded is climbing and the user still reports a frozen tile, the problem is downstream of the decoder — rendering, the element, or the application.

6. Jitter and the jitter buffer

Jitter on its own is a poor number to alarm on, because a jitter buffer exists precisely to absorb it. The informative value is the average time a frame spent waiting in that buffer, which you compute from two counters:

if (r.jitterBufferDelay != null && r.jitterBufferEmittedCount) {
  out.jbufMs = Math.round((r.jitterBufferDelay / r.jitterBufferEmittedCount) * 1000);
}
Enter fullscreen mode Exit fullscreen mode

That is total buffer delay divided by frames emitted, scaled to milliseconds: the average per-frame hold. A buffer sitting in the tens of milliseconds is doing its job. A buffer climbing into the hundreds means the network is delivering frames unevenly enough that latency is the price being paid to keep the picture smooth — and for a live support call, that trade is often the wrong one.

The table that fits on one screen

The rendering that is actually useful is thirteen rows and no graphs:

const rows = [
  ['Connection',     (s.connState || '-') + ' / ' + (s.iceState || '-')],
  ['Bitrate in',     s.bitrateKbps != null ? s.bitrateKbps + ' kbps' : '-'],
  ['Resolution',     s.w ? s.w + ' x ' + s.h : '-'],
  ['FPS in',         s.fps != null ? s.fps + ' fps' : '-'],
  ['Packets lost',   s.loss != null ? s.loss : '-'],
  ['Jitter',         s.jitterMs != null ? s.jitterMs + ' ms' : '-'],
  ['Jitter buffer',  s.jbufMs != null ? s.jbufMs + ' ms' : '-'],
  ['RTT',            s.rttMs != null ? s.rttMs + ' ms' : '-'],
  ['Path',           s.localType ? s.localType + ' -> ' + s.remoteType : '-'],
  ['Freezes',        s.freezeCount != null ? s.freezeCount : '-'],
  ['Bitrate out',    s.outKbps != null ? s.outKbps + ' kbps' : '-'],
  ['FPS out',        s.outFps != null ? s.outFps + ' fps' : '-'],
  ['Quality limit',  s.qlr != null ? s.qlr : '-'],
];
Enter fullscreen mode Exit fullscreen mode

Sampled once a second, that table answers the questions support actually asks: is there a path, is media arriving, at what quality, and if it is degraded, is the encoder blaming the network or the CPU.

How to read it in one pass

  1. Path shows the candidate types. relay -> relay on a cellular session is correct, not a bug.
  2. Bitrate in and FPS in flat or zero with Connection connected is a media problem, not a signaling problem.
  3. Frames received climbing while Frames decoded stays flat means the media arrived and could not be decoded.
  4. Quality limit: bandwidth and Quality limit: cpu are different bugs with different owners.
  5. A jitter buffer in the hundreds of milliseconds is the latency you are paying for smoothness.
  6. Freezes rising is the browser agreeing with your own freeze detection.

I built this sampling and rendering into the diagnostics panel of a self-hosted WebRTC kit, next to the recovery ladder that acts on it — ICE restart first, full rejoin when that fails — so the numbers are visible while the failure is happening rather than reconstructed from a log afterwards:

https://lowlatencykit.com/

The guide that covers the network layers these numbers are measuring is here:

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

Top comments (0)