Debugging Missing Simulcast Layers
You configured three encodings, the call connects cleanly, and the server only ever sees one stream. This guide is part of the Simulcast & SVC Implementation guide, and it settles one question with evidence instead of guesswork: of the layers you asked for, which were never negotiated, which were negotiated and then suspended, and which are negotiated, active, and simply not producing packets. Those three states look identical in the video element and have completely different fixes, and getStats() separates them in about four lines of code.
Context & Trade-offs
A simulcast encoding can disappear at three distinct depths, and identifying the depth first saves you from re-tuning bitrates that were never the problem.
Negotiation depth. If a=simulcast and the sdes:rtp-stream-id header extension do not survive into the applied local description, the sender has exactly one encoding — not a suspended one, not a broken one, one. The browser reports no rid at all, and nothing you do at runtime will create a second stream without a fresh offer. The usual causes are a hand-rewritten m-section, an answer that echoes back fewer a=rid lines than you sent, or a codec that Chromium routes through its SVC path.
Allocator depth. Chromium’s simulcast rate allocator fills the ladder bottom-up. The lowest layer receives its minimum bitrate first, and the next layer only switches on once the estimate covers the cumulative minimums beneath it plus a hysteresis margin of roughly 20–35% so layers do not flap on noise. With a 150 / 500 / 2000 kbps ladder and a measured uplink around 620 kbps you get two active layers and one suspended layer, permanently, with no error anywhere in the console. That is correct behaviour, not a bug — but it is indistinguishable from a configuration fault unless you read the estimate alongside the layer state.
Resolution is the second allocator-level gate and the one most people never look for. Chromium’s legacy simulcast layer limit, still enabled by default, caps the number of encodings by capture size rather than by what you requested: three layers require a capture of at least 960×540, two require at least 480×270, and anything below that ships as a single stream regardless of how many entries your sendEncodings array holds. Each derived layer has its own floor as well — scaleResolutionDownBy: 4.0 on a 640×360 capture asks the encoder for 160×90, well under the 320×180 (57,600 pixel) frame that Chrome’s quality scaler treats as the smallest useful size, so that entry is dropped rather than encoded. This is why a call that shows three layers on a 1080p webcam collapses to one the moment a user switches to a 320×240 virtual camera or a downscaled mobile front camera.
Encoder depth. The third case is a layer that is negotiated and marked active but whose bytesSent has stopped climbing. That is the encoder shedding work, and qualityLimitationReason names the axis. More useful than the instantaneous reason is qualityLimitationDurations, a cumulative seconds-per-reason map: diff it across two polls and you learn that the sender spent, say, 0.9 of the last second in cpu — a far stronger signal than a single sampled string, which flickers. Sustained cpu time on a three-layer VP8 ladder usually means you are on the software encoder; confirm with the probes in Detecting Hardware vs Software Encoding before you start deleting layers.
The evidence you can collect also differs by engine, which matters when a bug only reproduces on one browser. Chromium exposes the richest set: per-rid active, targetBitrate, qualityLimitationDurations, and a live SSRC table in the internals page. Firefox emits per-rid outbound-rtp reports and honours scaleResolutionDownBy, but historically leaves qualityLimitationReason at none, so encoder-depth diagnosis there relies on framesEncoded stalling while framesSent on the source keeps climbing. Safari negotiates a=rid but is the strictest about what it will echo in an answer, and its practical ceiling has long been two layers — so on Safari, treat a missing third layer as negotiation depth until the applied remote description proves otherwise. Write the classifier so a missing field degrades to “unknown” rather than throwing, and run it unchanged on all three.
Minimal Runnable Implementation
One poller classifies every rid you configured into the three depths above. The key move is comparing the set of rid values you asked for against the set that has an outbound-rtp report at all — a missing report and a suspended report mean opposite things.
const EXPECTED_RIDS = ['high', 'mid', 'low']; // must match your sendEncodings exactly
let previous = new Map();
async function classifyLayers(sender) {
const report = await sender.getStats();
const current = new Map();
let estimate = 0;
for (const s of report.values()) {
// availableOutgoingBitrate lives on the nominated candidate-pair, not on outbound-rtp
if (s.type === 'candidate-pair' && s.nominated && s.availableOutgoingBitrate) {
estimate = s.availableOutgoingBitrate;
}
if (s.type === 'outbound-rtp' && s.kind === 'video') {
current.set(s.rid ?? '(no rid)', s); // no rid at all => simulcast was never negotiated
}
}
for (const rid of EXPECTED_RIDS) {
const now = current.get(rid);
if (!now) {
// Depth 1: nothing to tune at runtime — the SDP never carried this layer
console.warn(`rid=${rid} NEVER NEGOTIATED — inspect a=simulcast / a=rid in the local description`);
continue;
}
const prev = previous.get(rid);
const deltaBytes = prev ? now.bytesSent - prev.bytesSent : 0;
// `active` is reported from Chrome 112+; older builds show targetBitrate === 0 instead
const suspended = now.active === false || now.targetBitrate === 0;
if (suspended) {
// Depth 2: the allocator turned it off — compare against the estimate before blaming config
console.warn(`rid=${rid} SUSPENDED estimate=${Math.round(estimate / 1000)} kbps`);
} else if (prev && deltaBytes === 0) {
// Depth 3: active but silent — the encoder is shedding this layer
const cpuMs = (now.qualityLimitationDurations?.cpu ?? 0) -
(prev.qualityLimitationDurations?.cpu ?? 0);
console.warn(`rid=${rid} STALLED reason=${now.qualityLimitationReason} cpuSecs=${cpuMs.toFixed(2)}`);
} else {
console.log(`rid=${rid} ok ${now.frameWidth}x${now.frameHeight} +${deltaBytes}B`);
}
}
previous = current;
}
setInterval(() => classifyLayers(videoSender), 1000); // 1 s poll matches the estimator's own cadence
The stats graph this walks is small but cross-referenced, and knowing the shape keeps you from reading the wrong object. Each outbound-rtp points at one shared media-source through mediaSourceId and at one shared transport through transportId, which is exactly why a per-layer resolution problem and a whole-connection bandwidth problem show up in different places.
outbound-rtp report and an active: false report are different diagnoses — only the second one is the allocator's doing.Reproduction Steps & Debugging Log Patterns
- Publish with a 1280×720 capture and a
150 / 500 / 2000 kbpsladder, then run the poller above for 15 seconds with an unthrottled uplink. All three rids should reportok. This is your control. - Re-run with the capture constrained to 640×360. Expect
rid=highto reportNEVER NEGOTIATED— the layer count was clamped at two before any bitrate logic ran, and no runtime call can bring it back. - Restore 720p and throttle the uplink to roughly 600 kbps in DevTools. Expect
rid=highto flip toSUSPENDEDwhile the printed estimate hovers near the throttle, and to return on its own once the throttle lifts. - Restore full bandwidth and pin the CPU with a parallel encode or a busy tab. Expect
STALLEDon the top rid withcpuSecsaccumulating close to 1.0 per poll. - Finally, dump
pc.localDescription.sdpand grep fora=simulcast,a=rid, andsdes:rtp-stream-id. If any are missing, you are at negotiation depth and steps 2 through 4 are irrelevant.
A healthy run and each failure mode print distinctly:
// healthy, 1280x720 capture, unthrottled
// rid=high ok 1280x720 +241802B
// rid=mid ok 640x360 +61190B
// rid=low ok 320x180 +18455B
// throttled to ~600 kbps — allocator suspension, self-healing
// rid=high SUSPENDED estimate=612 kbps
// rid=mid ok 640x360 +59021B
// CPU-bound — negotiated and active, but the encoder skipped it
// rid=high STALLED reason=cpu cpuSecs=0.94
Two rules keep this from producing false alarms. First, discard the opening window: the sender brings layers up in sequence rather than all at once, so a report that is suspended at t+1 s may be perfectly healthy at t+6 s. Start classifying only after the first 5 seconds of media. Second, require persistence — flag a layer only after three consecutive polls agree, which at a 1 s cadence costs you three seconds of detection latency and removes essentially every transient. The same debounce keeps automated alerting honest: a SUSPENDED verdict that clears within 5 seconds is congestion doing its job, while one that holds for a minute on a healthy 2 Mbps estimate is a real configuration fault worth paging on.
Step 5 catches the failure that most often survives every other check: SDP munging. Any code that rebuilds the video m-section — reordering payload types, stripping RTX, forcing a profile — must preserve the RID header extension a=extmap line, all a=rid:<id> send lines, and the a=simulcast:send line, in the same m-section. A naive filter that keeps only lines it recognises silently drops all five, and because the resulting SDP is still perfectly valid, setLocalDescription() succeeds and you get one unlabelled stream. The same discipline applies to the answer: if your server answers with a=simulcast:recv high alone, Chrome prunes the other two encodings to match. Munge by targeted substitution rather than reconstruction — the technique demonstrated in Munging SDP to Prefer Opus DTX — and diff before against after every time.
If the lines are intact locally but layers still never appear, the deviation is on the far side of the exchange — compare the m-section you sent against the one you got back using the technique in Debugging SDP m-line Mismatches, and check the SSRC list under VideoSender as described in Reading chrome://webrtc-internals Dumps.
Common Implementation Mistakes
- Reading a missing report as a suspended layer. No
outbound-rtpentry for aridmeans the encoding does not exist. RaisingmaxBitrateor togglingactiveon a sender that only has one encoding is a no-op that costs an afternoon. - Trusting the constraints you requested instead of the frame you got. A camera that quietly delivers 640×480 when you asked for 1280×720 clamps you to two layers. Always read
frameWidth/frameHeightoff themedia-sourcereport, not off your constraints object. - Sampling
qualityLimitationReasononce. The field flips betweenbandwidth,cpu, andnonewithin a second. DiffqualityLimitationDurationsacross two 1 s polls and act on the dominant reason. - Rebuilding the m-section instead of patching it. Any rewrite that reconstructs lines from a whitelist will drop
a=rid,a=simulcast, and the RIDa=extmap, and the result still parses. - Assuming a suspended top layer is a fault. With the estimate below the cumulative minimums, suspension is the allocator working correctly. Verify against
availableOutgoingBitratebefore changing anything — the interpretation patterns are in Interpreting getStats() for Congestion Signals.
FAQ
A rid has an outbound-rtp report but bytesSent never moves. Is that a network problem?
No. Bytes are counted at the sender, so a frozen counter means nothing was handed to the transport — the encoder skipped the layer or the allocator gave it zero. Network loss shows up as rising bytesSent alongside rising packetsLost and retransmittedPacketsSent, never as a flat send counter.
Why do two layers work but the third never appears, even on a fast uplink?
Check the capture size before anything else: three encodings need at least 960×540. If the capture is large enough, check the negotiated codec and the answer’s a=simulcast:recv list, then the ceiling spacing described in Simulcast with Three Quality Layers in Chrome.
How do I tell layer suspension apart from a server that is not forwarding?
Suspension is visible in the publisher’s own stats — active: false or a flat bytesSent on that rid. If the publisher shows three healthy rising counters and subscribers still see one quality, the layer exists on the wire and the fault is in selection, covered in Simulcast-Aware Forwarding.
Related: return to Simulcast & SVC Implementation, then read Choosing Simulcast vs SVC for Large Conferences and Configuring AV1 SVC Layers in WebRTC, where a single stream replaces the per-layer bookkeeping described here.