Reacting to Bandwidth Drops with RTCRtpSender Parameters
This deep-dive is part of the Adaptive Bitrate Streaming in WebRTC guide, and it answers one precise question: when availableOutgoingBitrate collapses mid-call, exactly how do you step the encoder down through RTCRtpSender.setParameters() fast enough to avoid freezes, without the loop oscillating once the link recovers? The naive version β βif estimate dropped, lower bitrateβ β flaps badly the moment the estimate jitters. The version below adds asymmetric reaction speed, hysteresis, and a slow recovery ramp.
Context & Trade-offs
A bandwidth drop has two failure modes if you mishandle it. React too slowly and packets queue in the pacer, RTT climbs, and the viewer sees a multi-second freeze before recovery. React too eagerly to noise and you pump quality up and down on every 1 s poll, which is more distracting than a stable lower tier. The estimate itself is noisy by design β GCC probes capacity and overshoots, so a single low reading does not mean sustained congestion.
The trade-off is therefore asymmetric. Down-steps should be fast and decisive: a drop below ~80% of your current ceiling, sustained for even 1β2 polls, warrants an immediate step down, because the cost of a freeze far exceeds the cost of a brief over-correction. Up-steps should be slow and skeptical: require the estimate to exceed the next tierβs needs by a 15β20% margin and hold there for 3β5 seconds before ramping, and ramp one tier at a time rather than jumping to the top. This matches the 15β20% switching thresholds recommended for layer changes and keeps recovery from re-triggering congestion. The shared 1 s getStats() poll keeps main-thread cost low while still catching rapid network state changes.
Minimal Runnable Implementation
// Bitrate/resolution tiers, highest quality first
const TIERS = [
{ maxBitrate: 2_500_000, scaleResolutionDownBy: 1 }, // 720p full
{ maxBitrate: 1_200_000, scaleResolutionDownBy: 1 }, // 720p reduced
{ maxBitrate: 600_000, scaleResolutionDownBy: 2 }, // 360p
{ maxBitrate: 250_000, scaleResolutionDownBy: 4 } // 180p
];
const DOWN_RATIO = 0.80; // step down if estimate < 80% of current ceiling
const UP_MARGIN = 1.15; // step up only if estimate > 115% of next tier's ceiling
const UP_HOLD_MS = 4000; // estimate must stay high this long before ramping up
const POLL_MS = 1000; // 1 s control loop
let tierIndex = 0; // current position in TIERS
let aboveSince = null; // timestamp the estimate first cleared the up-threshold
let writing = false; // in-flight setParameters() lock
async function readEstimate(pc) {
const stats = await pc.getStats();
for (const report of stats.values()) {
// availableOutgoingBitrate lives only on the transport report
if (report.type === 'transport' && report.availableOutgoingBitrate != null) {
return report.availableOutgoingBitrate; // bps
}
}
return null; // not ready yet β caller should hold
}
async function applyTier(sender, idx) {
if (writing) return; // serialize writes; never overlap setParameters()
writing = true;
try {
const params = sender.getParameters(); // fresh snapshot for a valid transactionId
const enc = params.encodings[0];
enc.maxBitrate = TIERS[idx].maxBitrate;
enc.scaleResolutionDownBy = TIERS[idx].scaleResolutionDownBy;
await sender.setParameters(params); // atomic write of bitrate + resolution
tierIndex = idx;
} finally {
writing = false;
}
}
async function controlTick(pc, sender) {
if (pc.connectionState !== 'connected') return; // Safari throws otherwise
const estimate = await readEstimate(pc);
if (estimate == null) return; // hold on missing estimate
const current = TIERS[tierIndex];
// DOWN: fast and decisive
if (estimate < current.maxBitrate * DOWN_RATIO && tierIndex < TIERS.length - 1) {
aboveSince = null; // cancel any pending up-ramp
await applyTier(sender, tierIndex + 1);
return;
}
// UP: slow, with hysteresis dwell time
if (tierIndex > 0) {
const nextUp = TIERS[tierIndex - 1];
if (estimate > nextUp.maxBitrate * UP_MARGIN) {
aboveSince = aboveSince ?? performance.now();
if (performance.now() - aboveSince >= UP_HOLD_MS) {
aboveSince = null;
await applyTier(sender, tierIndex - 1); // ramp one tier only
}
} else {
aboveSince = null; // estimate fell back; reset the dwell timer
}
}
}
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
setInterval(() => controlTick(pc, sender), POLL_MS);
The two guards that make this production-safe are the writing lock (prevents overlapping setParameters() calls that throw InvalidStateError) and the aboveSince dwell timer (the hysteresis that turns a noisy estimate into a stable tier decision).
Reproduction Steps & Debugging Log Patterns
- Start a call, then throttle the uplink β Chrome DevTools βSlow 3Gβ or
tc qdisc add dev eth0 root netem rate 400kbit. Within 1β2 poll ticks the controller should step down. Addconsole.log('tier', tierIndex, 'est', Math.round(estimate/1000)+'kbps')tocontrolTick. - Watch the down-step fire. Expected console output as the link drops:
tier 0 est 2100kbps tier 0 est 410kbps // estimate < 80% of 2.5M ceiling tier 1 est 380kbps // stepped down tier 2 est 360kbps // stepped down again toward the 600k tier - Confirm the encoder actually followed by reading
outbound-rtpstats:frameHeightshould halve whenscaleResolutionDownBygoes 1 to 2, andtargetBitrateshould track the newmaxBitrate. IfframeHeightdoes not move, the write was rejected β checkconnectionStateand that you did not reuse a staleparamsobject. - Remove the throttle and watch recovery. The estimate climbs immediately, but the controller must wait
UP_HOLD_MSbefore ramping. Expected pattern:tier 2 est 1400kbps // above 115% of tier 1 ceiling, dwell starts tier 2 est 1500kbps // still dwelling (< 4 s elapsed) tier 1 est 1500kbps // dwell satisfied, ramped one tier - Verify no oscillation by holding a marginal link (estimate hovering near a threshold). With hysteresis,
tierIndexshould settle and stay; if it ticks up and down every few seconds, widenUP_MARGINor lengthenUP_HOLD_MS.
Common Implementation Mistakes
- Symmetric thresholds. Using the same margin for up and down makes the loop oscillate around any tier boundary. Down must be fast (80%), up must be skeptical (115% plus dwell).
- Jumping straight to the top tier on recovery. Ramping multiple tiers at once re-saturates the link and immediately triggers another drop. Ramp exactly one tier per up-step.
- No write lock. Overlapping
setParameters()calls during rapid drops throwInvalidStateErrorand silently lose updates. Serialize with an in-flight flag. - Reusing a cached
paramsobject. ThetransactionIdgoes stale between ticks; always callgetParameters()immediately before eachsetParameters(). - Reading the estimate from the wrong report.
availableOutgoingBitrateis ontransport, neverinbound-rtp; the latter returnsundefinedand the loop never reacts.
FAQ
How fast should a down-step react?
Within one to two 1 s polls. A sustained estimate below 80% of your current ceiling means the pacer is already backing up, and every extra second risks a visible freeze. The cost of an over-eager down-step is a brief quality dip; the cost of a late one is a multi-second stall, so bias toward reacting.
Why ramp up one tier at a time instead of jumping?
Because the estimate after recovery is optimistic β GCC has not yet re-probed the higher ceiling. Jumping to the top instantly re-floods the link and forces another drop, producing exactly the oscillation hysteresis is meant to prevent. One-tier ramps let GCC confirm headroom at each level.
Related: this walkthrough lives under Adaptive Bitrate Streaming in WebRTC; for the estimator internals behind availableOutgoingBitrate see Bandwidth Estimation & Congestion Control, and for advanced handling of cellular handoffs see Tuning WebRTC Bandwidth Estimator for Unstable Networks.