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;
}
});
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;
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;
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
...
}
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;
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);
}
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 : '-'],
];
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
-
Pathshows the candidate types.relay -> relayon a cellular session is correct, not a bug. -
Bitrate inandFPS inflat or zero withConnectionconnected is a media problem, not a signaling problem. - Frames received climbing while
Frames decodedstays flat means the media arrived and could not be decoded. -
Quality limit: bandwidthandQuality limit: cpuare different bugs with different owners. - A jitter buffer in the hundreds of milliseconds is the latency you are paying for smoothness.
-
Freezesrising 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:
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)