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, and how quickly the overshoot is even reported back to the sender depends on the feedback format negotiated, covered in Transport-CC vs REMB Feedback.
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 — the recovery half of the loop is worth designing deliberately, as Ramping Bitrate Back Up After Congestion works through in detail. 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.
Why the estimate always arrives late
The asymmetry is not a stylistic preference; it falls out of how long the signal takes to reach you. GCC’s delay-based controller lives on the sender and works from transport-wide feedback packets, which the receiver emits roughly every 50–100 ms. Its overuse detector compares the inter-group arrival delay against an adaptive threshold and will not flip from normal to overuse until the trend is consistent across several feedback reports — typically 100–300 ms after the queue physically starts building. Only then does the target bitrate get cut, and only then does the new value appear in the transport report that your 1 s poll happens to sample. Stack the pieces up and the worst case is that you observe a drop about 1.2–1.4 s after the link actually degraded. On relayed calls it is worse still: a TURN hop adds 20–40 ms one-way in each direction, which lengthens the feedback loop and delays the detector by roughly another 50–80 ms.
That delay matters because the pacer keeps draining at the old target the entire time. A sender pinned at 2.5 Mbps on a link that has just collapsed to 400 kbps accumulates about 2.1 Mbit of excess per second — roughly 260 kB of queue — while the link drains only about 50 kB/s. One second of over-sending therefore takes about five seconds to clear, and every one of those seconds is frames arriving late or not at all. This is the arithmetic behind “bias toward reacting”: a wrong down-step costs one tier of sharpness for a few seconds, while a late down-step costs a queue you cannot un-build.
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). Note that the explicit scaleResolutionDownBy steps set a hard floor on resolution while the encoder’s own internal adaptation is still governed by the sender’s degradationPreference: Resolution vs Framerate setting, so decide which of the two owns the decision rather than letting them fight.
Read as a state machine, the loop has exactly four transitions and one dwell state, and every path returns to the steady state on the next 1 s poll:
Adapting the same loop on a simulcast sender
params.encodings[0] addresses a single stream. On a sender configured for three layers the array holds one entry per rid in send order, and setParameters() rejects any call that changes the array’s length or reorders it — you must mutate the existing objects in place and hand the same array back. The more important behavioural difference is that Chrome’s bitrate allocator splits one aggregate estimate across all active encodings, so a per-layer maxBitrate is a cap the allocator respects, never a floor it guarantees. Cutting all three caps proportionally therefore starves every layer at once; switching a layer off with active = false returns its whole share to the survivors and is usually the better first move, because a subscriber pulling the half-scale layer sees no change at all while the top layer disappears.
// Shed the top simulcast layer instead of squeezing all three
function shedTopLayer(sender, estimateBps) {
const params = sender.getParameters(); // fresh transactionId every time
const encs = params.encodings; // [high, half-scale, quarter-scale]
// Keep the top layer only if the estimate covers it plus 15% headroom
encs[0].active = estimateBps > encs[0].maxBitrate * 1.15;
// Never disable the bottom layer — it is the last resort for weak subscribers
encs[encs.length - 1].active = true;
return sender.setParameters(params); // mutate in place, same array
}
Which layers actually reach each viewer is then the forwarding side’s decision, described in Simulcast with Three Quality Layers in Chrome and, from the server’s perspective, Bandwidth-Aware Layer Selection in an SFU. Deactivating a layer takes effect on the next encoded frame, but reactivating it costs a keyframe on that rid, so the same one-tier-per-ramp discipline applies: flapping active produces a keyframe storm that consumes the headroom you just recovered.
Reproduction Steps & Debugging Log Patterns
Over a 30 s throttle-and-release run, the trace you are trying to produce has a sharp two-step descent, a flat floor while the link is capped, and a single delayed step back up after the dwell timer expires:
- 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. The wider set of counters worth watching alongside them —nackCount,pliCountand queueing delay — is catalogued in Interpreting getStats() for Congestion Signals. 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.
Failure mode: the ceiling moves but the encoder does not
The most common way this loop appears to work while doing nothing is that setParameters() resolves cleanly, maxBitrate reads back at the new value, and yet outbound-rtp.targetBitrate plateaus well above it. The cause is the encoder’s own minimum for the configured resolution: libvpx and OpenH264 refuse to encode 720p meaningfully below roughly 300–400 kbps, so a bitrate-only cut past that point is clamped, the sender keeps over-sending, and qualityLimitationReason sits at bandwidth while frameHeight never changes. The fix is the one the tier table already encodes — every large bitrate cut must be paired with a scaleResolutionDownBy step, because halving the linear dimension quarters the pixel count and brings the codec’s minimum down with it. Diagnose it by diffing frameHeight and targetBitrate across two polls; if the first is constant while the second refuses to fall, the resolution step is missing or was rejected. A frame-by-frame view of the same clamp is visible in a dump, as Reading chrome://webrtc-internals Dumps explains.
Browser behaviour around the read side differs too. Chrome and Firefox both expose availableOutgoingBitrate on the transport report once transport-wide congestion control is negotiated. Older Safari and iOS WKWebView builds surface it only on the nominated candidate-pair, so a loop that reads exclusively from transport sits on null forever and silently never adapts on those clients — worth a fallback:
// Safari/iOS fallback: read the estimate off the nominated candidate pair
for (const report of stats.values()) {
if (report.type === 'candidate-pair' && report.nominated &&
report.availableOutgoingBitrate != null) {
return report.availableOutgoingBitrate; // same units (bps) as transport
}
}
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. - Running the control loop on a throttled timer. Chrome clamps
setIntervalin a hidden tab to roughly once per minute, so a backgrounded sender stops stepping down entirely and rides a collapsed link until the tab is focused again. Drive the loop from a worker, or gate it ondocument.visibilityStateand force a full re-evaluation on the nextvisibilitychange. - Treating the estimate as measured throughput.
availableOutgoingBitrateis the congestion controller’s target, not what you are actually sending. Compare it againstoutbound-rtp.bytesSentdeltas before concluding the encoder is under-running; a target of 1.2 Mbps against 300 kbps of real output means the encoder, not the network, is the limit.
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.
Does a network handover break the loop?
It produces one spurious down-step if you let it. After a Wi-Fi to cellular switch the connection re-nominates a candidate pair and the congestion controller restarts from its default start bitrate — a few hundred kbps — so the very next poll sees an estimate far below the current ceiling and the controller drops two tiers on a link that may be perfectly healthy. Suppress it by pausing the loop for about 2 s after connectionstatechange returns to connected, then resuming from the tier you were on rather than the one the cold estimate suggests. Handling Wi-Fi to Cellular Network Handover covers the transport side of the same event.
Should I cut bitrate, resolution, or both?
Both, in fixed pairs. Video quality tracks bits per pixel, not bits alone: 720p30 needs roughly 1.2–2.5 Mbps to look clean, so holding 720p at 600 kbps drops you to about a third of the bits each pixel needs and the result is blocking and smearing across the whole frame. Halving the linear dimension to 360p quarters the pixel count, which makes the same 600 kbps generous. Tie the two together in the tier table — as the TIERS array above does — so no tier can ever ask the encoder for a bitrate its resolution cannot use.
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.