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.

Capture resolution decides how many encodings Chromium creates A horizontal resolution scale divided into three regions. Below 480 by 270 only one encoding is created. From 480 by 270 up to just under 960 by 540 two encodings are created. From 960 by 540 upward all three encodings survive. Stacks of blocks above each region show which rids exist, and a note explains that a quarter-scale layer derived from a 640 by 360 capture falls below the 320 by 180 floor. How many encodings survive, by capture size rid=low only rid=low rid=mid rid=low rid=mid rid=high 1 encoding 2 encodings 3 encodings 320×180 480×270 640×360 960×540 1280×720 1920×1080 Purple ticks are hard cut points: the encoding count is clamped before your maxBitrate values are read. A 640×360 capture with scaleResolutionDownBy 4.0 asks for 160×90 — under the 320×180 floor, so that entry is dropped. Fix the capture constraints first; re-spacing bitrate ceilings cannot recover a clamped ladder.
Capture size clamps the encoding count before any bitrate logic runs, and each derived layer must still clear the encoder's own 320×180 floor.

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.

Shape of the per-rid sender stats graph One media-source report holding the capture resolution and frame rate links by mediaSourceId to three outbound-rtp reports, one per rid. The high rid is inactive with a flat byte counter, the mid and low rids are active and rising. All three link by transportId to a single transport and candidate-pair report that carries the available outgoing bitrate shared between them. One capture, three reports, one shared estimate media-source width 1280 height 720 framesPerSecond 30 one per track, not per rid outbound-rtp rid=high active false bytesSent flat, targetBitrate 0 outbound-rtp rid=mid 640×360 at 30 fps bytesSent rising outbound-rtp rid=low 320×180 at 15 fps bytesSent rising candidate-pair availableOutgoingBitrate 620 kbps shared by all rids mediaSourceId transportId A rid with no box here was never negotiated; a box with active false was negotiated and then switched off.
The absence of an 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

  1. Publish with a 1280×720 capture and a 150 / 500 / 2000 kbps ladder, then run the poller above for 15 seconds with an unthrottled uplink. All three rids should report ok. This is your control.
  2. Re-run with the capture constrained to 640×360. Expect rid=high to report NEVER NEGOTIATED — the layer count was clamped at two before any bitrate logic ran, and no runtime call can bring it back.
  3. Restore 720p and throttle the uplink to roughly 600 kbps in DevTools. Expect rid=high to flip to SUSPENDED while the printed estimate hovers near the throttle, and to return on its own once the throttle lifts.
  4. Restore full bandwidth and pin the CPU with a parallel encode or a busy tab. Expect STALLED on the top rid with cpuSecs accumulating close to 1.0 per poll.
  5. Finally, dump pc.localDescription.sdp and grep for a=simulcast, a=rid, and sdes: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.

What a rebuilt m-section loses The left panel shows the m-section produced by createOffer with the rtpmap line, the rtp-stream-id extmap line, three a=rid send lines and the a=simulcast send line. The right panel shows the same section after a rewrite that keeps only recognised lines: the extmap, the three rid lines and the simulcast line have been removed, leaving one unlabelled stream and outbound-rtp reports with no rid field. Before — emitted by createOffer() After — m-section rebuilt line by line m=video 9 UDP/TLS/RTP/SAVPF 96 97 a=rtpmap:96 VP8/90000 a=extmap:10 sdes:rtp-stream-id a=rid:high send a=rid:mid send a=rid:low send a=simulcast:send high;mid;low m=video 9 UDP/TLS/RTP/SAVPF 96 a=rtpmap:96 VP8/90000 a=extmap:10 sdes:rtp-stream-id a=rid:high send a=rid:mid send a=rid:low send a=simulcast:send high;mid;low rewrite 3 outbound-rtp reports each carries rid and its own SSRC 1 outbound-rtp report rid is undefined; setLocalDescription still resolves
Struck lines are what a keep-what-I-recognise rewrite removes; the SDP stays valid, which is why the failure is silent.

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

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.