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

  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. 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.

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.

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.