Forwarding Simulcast Layers by Subscriber Bandwidth
This guide is part of the Simulcast-Aware Forwarding guide, and it answers one precise question: given a subscriber’s continuously updated downlink estimate, which of the three incoming simulcast spatial layers should the server forward right now, and how do you switch without thrashing or corrupting the decoder? The decision sounds trivial — pick the highest layer that fits the bandwidth — but doing it naively produces a stream that flips between resolutions every second, requests keyframes in storms, and downgrades on momentary jitter. The implementation below maps measured bitrate to a layer with hysteresis, debounces the switch, and requests a keyframe only on the way up.
Context & Trade-offs
A three-layer simulcast publisher typically sends roughly 1700 kbps (high, 720p), 500 kbps (medium, 360p), and 180 kbps (low, 180p). The subscriber’s available downlink comes from transport-wide congestion control or REMB feedback, the same estimate produced by the bandwidth estimation pipeline and consumed by the broader Bandwidth-Aware Layer Selection in an SFU policy that arbitrates across all of a subscriber’s tracks at once.
The core trade-off is responsiveness versus stability. Switch the instant the estimate crosses a layer’s bitrate and you get oscillation: an estimate hovering near 800 kbps will flap between medium and high several times a minute, and every upswitch costs a keyframe request that briefly inflates the publisher’s bitrate. The fix is asymmetric thresholds plus a debounce. Upswitch only when the estimate clears the target layer’s cost plus a margin (so you have headroom, not a stream sitting at 99% of capacity), and downswitch faster than you upswitch, because a starved decoder freezes while an over-provisioned one merely wastes a little headroom. A practical rule: require ~1.3x the target layer’s bitrate to climb into it, drop immediately when the estimate falls below ~1.0x the current layer, and hold any decision for a 1–2 s debounce window before acting.
| Estimated downlink | Forward layer | Resolution / bitrate |
|---|---|---|
| < 300 kbps | Low | 320x180 ~180 kbps |
| 300–800 kbps | Medium | 640x360 ~500 kbps |
| > 800 kbps | High | 1280x720 ~1700 kbps |
These boundaries already bake in headroom — the 800 kbps high-layer entry point sits well above the 500 kbps medium cost, leaving room for the estimate to wobble without forcing a downswitch, and comfortably below the 1700 kbps the high layer actually consumes so the climb only happens with real surplus.
A second consideration is what the estimate represents on this particular subscriber. A relayed path through TURN carries higher RTT and more loss than a direct one, so the same nominal estimate is riskier on a relayed subscriber; the conservative entry margins above absorb that, but on links you know are relayed it is worth nudging the high-layer entry up another 10–15%. The estimate also lags reality — congestion-control output trails the link by at least one round trip — which is the deeper reason the upswitch is debounced rather than acted on instantly: a single optimistic sample during a brief idle period must not pull a subscriber up into a layer the link cannot sustain once real traffic resumes. The downswitch, by contrast, is the safety valve and runs with no delay at all, because the cost of holding a too-high layer for even one second is a visible freeze while the receive buffer drains.
Minimal Runnable Implementation
// Map a subscriber's estimated downlink (kbps) to a target spatial layer, with hysteresis
// and a debounce so transient dips don't trigger a switch. Layer 0 = high, 2 = low.
class LayerSelector {
constructor({ requestKeyframe }) {
this.requestKeyframe = requestKeyframe; // (layerIndex) => emit RTCP PLI/FIR upstream
this.current = 2; // start on the low layer, climb when proven safe
this.pendingSince = 0;
this.pendingTarget = null;
this.DEBOUNCE_MS = 1500; // hold a candidate decision before committing
// [layerIndex, upswitch-entry kbps, downswitch-exit kbps] — asymmetric on purpose
this.LAYERS = [
[0, 800, 650], // HIGH: need >800 to enter, drop out below 650
[1, 300, 240], // MEDIUM: need >300 to enter, drop out below 240
[2, 0, 0], // LOW: always reachable, the floor
];
}
// Pick the best layer index the estimate can support (upswitch uses the entry threshold).
_targetFor(kbps) {
for (const [idx, entry] of this.LAYERS) {
if (kbps >= entry) return idx; // LAYERS is ordered high→low, first fit wins
}
return 2;
}
onEstimate(kbps, now = Date.now()) {
// Fast path down: if we've fallen below the CURRENT layer's exit threshold, drop now.
const exit = this.LAYERS.find(([idx]) => idx === this.current)[2];
if (kbps < exit && this.current < 2) {
this.current += 1; // demote one step immediately — no debounce on the way down
this.pendingTarget = null;
return this.current; // demotion needs no keyframe: lower layer is already flowing
}
const target = this._targetFor(kbps);
if (target >= this.current) { // not an upswitch (same or lower quality) — nothing to climb to
this.pendingTarget = null;
return this.current;
}
// Upswitch candidate: require the target to stay viable for the whole debounce window.
if (this.pendingTarget !== target) {
this.pendingTarget = target;
this.pendingSince = now;
return this.current;
}
if (now - this.pendingSince >= this.DEBOUNCE_MS) {
this.current = target;
this.pendingTarget = null;
this.requestKeyframe(target); // ask publisher for a keyframe on the layer we move INTO
}
return this.current;
}
}
The keyframe request is the load-bearing detail on every upswitch: you cannot start forwarding a higher spatial layer mid-GOP, so requestKeyframe emits an RTCP PLI (or FIR) for the target layer’s SSRC, and the per-subscriber forwarder keeps relaying the old layer until that keyframe actually lands — the splicing and header-rewrite mechanics for that cutover live in Simulcast-Aware Forwarding. Demotion needs no keyframe because the lower layer is already arriving and you simply start forwarding its next packets.
Two details turn this skeleton into something production-safe. First, the keyframe request must itself be bounded: after firing the PLI, start a 200–400 ms timer, and if no keyframe for the target layer has arrived by then, fire exactly one more, capping retries at 3 before abandoning the upswitch and staying on the current layer. Without that cap a publisher whose keyframe path is broken pulls a steady stream of PLIs upstream. Second, the selector should be fed the smoothed estimate, not the raw per-sample value: a short moving window over the last few hundred milliseconds of REMB or transport-wide feedback removes the single-sample spikes that would otherwise start a debounce window for no reason. The class above assumes the caller has already smoothed the input, which keeps the selection logic itself purely about thresholds and timing.
Reproduction Steps & Debugging Log Patterns
- Drive the selector with a synthetic estimate that ramps 150 → 1200 kbps over 10 s, then collapses to 200 kbps. Log every
onEstimatereturn alongside the input. - Confirm the climb is gated: the move into HIGH should appear ~1.5 s after the estimate first crosses 800, not immediately.
est= 780kbps layer=1 (medium) # below 800 entry → stays on medium
est= 860kbps layer=1 pending=0 t=0 # crossed entry, debounce starts
est= 910kbps layer=1 pending=0 t=900 # still within debounce window
est= 950kbps layer=0 KEYFRAME req # 1500ms elapsed → commit + PLI for high
- Now collapse the estimate to 200 kbps and confirm the drop is immediate and keyframe-free:
est= 600kbps layer=0->1 DROP # below high exit(650), demote now, no PLI
est= 200kbps layer=1->2 DROP # below medium exit(240), demote to low
- Feed an oscillating estimate hovering at 790–810 kbps for 30 s and confirm zero switches occur — the gap between the 800 entry and 650 exit absorbs the wobble. If you see repeated switches here, your exit threshold is too close to the entry.
- Check upstream
pliCountin the publisher’s stats: it should increment exactly once per committed upswitch, never during the debounce window or on any demotion.
Common Implementation Mistakes
- Symmetric thresholds. Using the same kbps boundary to enter and leave a layer guarantees oscillation at the boundary. Always set the exit threshold meaningfully below the entry threshold.
- Debouncing the downswitch too. Holding a demotion behind a debounce window keeps forwarding a layer the link can no longer carry, so the subscriber freezes for the full window. Drop immediately; only gate the way up.
- Requesting a keyframe on every estimate. Firing a PLI on each sample while waiting for the keyframe to arrive creates a request storm that inflates publisher bitrate. Request once on commit, then wait with a timeout.
- Mapping against declared instead of measured bitrate. A thermally throttled publisher may declare 1700 kbps but send 600; mapping a subscriber to “high” then starves it. Drive thresholds off the layer’s measured send bitrate where available.
- Ignoring round-trip lag in the estimate. Congestion-control estimates trail reality by an RTT or more; upswitching the moment the number looks good can re-congest a link that is still draining. The debounce window doubles as protection against acting on a stale spike.
FAQ
Should the thresholds scale with the publisher’s actual layer bitrates? Yes — hardcoded kbps numbers drift out of correctness when a publisher reconfigures its encoder. Derive each entry threshold from the layer’s measured send bitrate times a margin (~1.3x) rather than fixing them in code, so the mapping tracks what the publisher is actually producing.
What debounce window works best in practice? 1–2 seconds for upswitches balances responsiveness against stability; below ~1 s you start reacting to congestion-control noise, and above ~2 s the climb feels sluggish to users on recovering networks. Keep demotion at zero debounce regardless.
Related: the parent Simulcast-Aware Forwarding guide covers the RTP rewriting and keyframe-splice mechanics this selector triggers, while Bandwidth-Aware Layer Selection in an SFU places the per-track decision inside the server-wide allocation policy.