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.

Asymmetric down-step versus up-step policy A five-row comparison matrix. Down-steps trigger below 80 percent of the current ceiling with no dwell, one tier immediately, costing only a brief quality dip. Up-steps require 115 percent of the next tier held for four seconds, one tier per ramp, because a wrong call re-saturates the link. Reaction policy is deliberately asymmetric Property Down-step (fast) Up-step (slow) Trigger estimate < 80% of ceiling estimate > 115% of next tier Dwell before acting 1–2 polls (1–2 s) 4 s continuous hold Step size one tier, immediately one tier per ramp Cost of a wrong call brief quality dip re-saturation, oscillation Bias act on suspicion act on proof
Down and up transitions use different thresholds, dwell times and error budgets.

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:

Control-loop state machine From the steady state at tier N, a drop below eighty percent of the ceiling steps directly to tier N plus one and clears the dwell timer. An estimate above one hundred fifteen percent of the next tier enters a four second dwell before stepping to tier N minus one. Both paths return to the steady state on the next one second poll. steady @ tier N poll every 1 s est < 80% of ceiling applyTier(N + 1) clear dwell est > 115% next tier dwell 4 s reset if est falls applyTier(N - 1) next poll — N becomes N + 1 next poll — N becomes N - 1 both writes serialized by the in-flight lock no match: stay
Four transitions, one dwell state: the down path acts immediately, the up path must survive a timer.

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:

Estimate versus applied tier ceiling over a 30 second test The estimate sits near 2.1 megabits until the uplink is throttled at six seconds, collapsing to about 400 kilobits. The tier ceiling follows within two polls, stepping from 2.5 megabits to 1.2 and then 600 kilobits. When the throttle is removed at twenty-one seconds the estimate jumps to 1.5 megabits but the ceiling only rises one tier after a four second dwell. 2.5M 1.2M 600k 0 0 s 10 s 20 s 30 s dwell 4 s uplink throttled two fast down-steps throttle removed one-tier ramp availableOutgoingBitrate applied tier ceiling
The estimate collapses in one poll; the ceiling follows in two, but recovery is deliberately delayed.
  1. 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. Add console.log('tier', tierIndex, 'est', Math.round(estimate/1000)+'kbps') to controlTick.
  2. 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
    
  3. Confirm the encoder actually followed by reading outbound-rtp stats: frameHeight should halve when scaleResolutionDownBy goes 1 to 2, and targetBitrate should track the new maxBitrate. The wider set of counters worth watching alongside them — nackCount, pliCount and queueing delay — is catalogued in Interpreting getStats() for Congestion Signals. If frameHeight does not move, the write was rejected — check connectionState and that you did not reuse a stale params object.
  4. Remove the throttle and watch recovery. The estimate climbs immediately, but the controller must wait UP_HOLD_MS before 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
    
  5. Verify no oscillation by holding a marginal link (estimate hovering near a threshold). With hysteresis, tierIndex should settle and stay; if it ticks up and down every few seconds, widen UP_MARGIN or lengthen UP_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

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.