Ramping Bitrate Back Up After Congestion

A sender that collapses from 2.5 Mbps to 400 kbps in under half a second, and is still sitting at 500 kbps ninety seconds after the interfering download finished, has solved exactly half the problem. This guide is part of the Adaptive Bitrate Streaming in WebRTC guide, and it covers the other half: how to get quality back after a congestion event, why the estimator climbs roughly two orders of magnitude slower than it backs off, and how to drive recovery from setParameters() without the ramp re-creating the congestion it is recovering from.

Context & Trade-offs

Google Congestion Control is deliberately asymmetric. One overuse signal from the delay-based controller is enough to set the target to 0.85 Γ— the measured receive rate, and with transport-wide feedback arriving every 50–100 ms that decision lands within 200–500 ms. Recovery runs on the opposite schedule. Once the estimator has recorded a maximum near the point where congestion appeared, it leaves multiplicative increase and enters additive increase, adding roughly one packet’s worth of throughput per response interval of RTT + 100 ms. At a 50 ms RTT that is about 60–80 kbps of growth per second, so clawing back a 2.1 Mbps deficit takes 20–60 seconds of clean network. The loss-based half of the controller is no faster: below 2% loss it grants 5% per interval, between 2% and 10% it holds flat, and it only cuts above 10%.

Estimator phase Trigger Rate change Time to cross 400 kbps β†’ 2.5 Mbps
Overuse decrease one overuse signal Γ— 0.85 of measured receive rate 200–500 ms (downward)
Additive increase delay normal, near recorded max β‰ˆ +1 packet per RTT + 100 ms 20–60 s
Multiplicative increase delay normal, far below recorded max Γ— 1.05 per response interval 3–6 s, rarely entered after a drop
Padding probe encoder under its ceiling, link idle direct jump to the measured probe rate 1–2 s per attempt
Asymmetric collapse and recovery timeline Send rate over sixty seconds: a vertical drop at ten seconds from 2.5 Mbps to 400 kbps, then two recovery traces β€” a nearly flat additive-increase line that reaches under 900 kbps by sixty seconds, and a probe-driven staircase that regains 2.5 Mbps within about thirty seconds. 2.5M 2.0M 1.0M 0 0 s 15 s 30 s 45 s 60 s congestion event x0.85 decrease lands in 200-500 ms additive increase alone probe, confirm, commit no probing: 20-60 s to full quality probe-driven ramp: 8-15 s
The same link, two recovery policies: passive additive increase versus a probe-confirm-commit staircase.

There is a second, nastier reason recovery stalls, and it is your own code’s fault rather than the estimator’s. Send-side bandwidth estimation measures traffic you actually emit; it cannot observe capacity you never use. If your down-step clamped maxBitrate to 400 kbps, the encoder obediently produces 400 kbps, the estimator sees 400 kbps of well-behaved traffic, and nothing in the feedback stream ever suggests the link now carries 4 Mbps. The clamp becomes a ratchet: the estimate cannot rise past what you send, and you refuse to send more until the estimate rises. Browsers partially escape this by injecting padding or RTX packets when they detect the application is sending below its allocation, but that probing is periodic and conservative β€” typically capped near 2Γ— the current rate and spaced seconds apart, which is why the details of the feedback format matter and are worth reading in Transport-CC vs REMB Feedback.

The lever you hold is the ceiling, and it is important to be precise about what raising it does. maxBitrate is permission, not instruction: setting it to 1.2 Mbps does not make the sender emit 1.2 Mbps, because GCC still allocates only what its estimate allows. What the higher ceiling does is put the sender back in a state where the browser has headroom to probe into and where a rising estimate can immediately be converted into real bits. The mechanics of the estimate itself are covered in Bandwidth Estimation & Congestion Control; here we only need the consequence, which is that every up-step is a hypothesis about capacity that must be tested and cheaply retracted.

Restoring resolution is a different and more expensive class of change than restoring bitrate. Moving scaleResolutionDownBy from 2 to 1 forces the encoder to reconfigure and emit a fresh keyframe, typically 30–60 kB, delivered as a burst on top of your normal rate. On a link that just recovered, that burst is precisely the kind of transient that re-triggers overuse and undoes the ramp β€” the same reason a media server times its upgrades carefully, as described in Switching Layers Without Visible Glitches. Restore bitrate first, and only reach for resolution after two consecutive successful bitrate steps.

Step size is the last trade-off to settle before writing code. A 1.3–1.5Γ— step is small enough that a failed attempt costs one confirm window and a barely perceptible wobble, and large enough that five or six successful steps carry 400 kbps back to 2.5 Mbps in well under 30 seconds. Doubling on each attempt reaches the top in three steps but makes every failure expensive: the overshoot is large, the queue it builds takes longer to drain, and the doubling backoff then locks you out for tens of seconds. The confirm window has a floor set by feedback cadence β€” with transport-wide feedback every 50–100 ms and a 1 s poll, anything under about 3 seconds is judging the estimate on three or four samples, which is how a stable link gets misread as saturated.

Minimal Runnable Implementation

The policy is worth externalising so operations can tune it per deployment without a rebuild:

; ramp-policy.conf β€” recovery half of the adaptation loop
[ramp]
step_factor     = 1.4    ; probe ceiling = committed ceiling x 1.4
confirm_ms      = 4000   ; watch window before commit or revert
follow_ratio    = 0.90   ; estimate must reach 90% of the probed ceiling
rtt_guard_ms    = 20     ; abort if smoothed RTT climbs more than this
loss_guard      = 0.02   ; abort above 2% fractional loss
cooldown_ms     = 5000   ; base wait between probes; doubles on every failure
max_cooldown_ms = 60000  ; cap for the doubling backoff
scale_hold      = 2      ; successful bitrate steps before restoring resolution

The controller below implements probe, confirm, commit or revert. It assumes a down-step path already exists and only owns the upward direction:

const P = {
  stepFactor: 1.4, confirmMs: 4000, followRatio: 0.90,
  rttGuardMs: 20, lossGuard: 0.02,
  cooldownMs: 5000, maxCooldownMs: 60000, scaleHold: 2
};
const TOP_CEILING = 2_500_000; // never probe above the tier ladder's top

let state = 'STEADY';
let committedCeiling = 400_000; // where the down-step left us
let probedCeiling = 0;
let currentScale = 2;           // 360p after the drop; 1 == full resolution
let baseline = null;            // signals captured at probe open
let probeOpenedAt = 0;
let cooldown = P.cooldownMs;
let nextProbeAt = 0;
let streak = 0;                 // consecutive successful commits

async function sample(pc) {
  const out = { estimate: null, rtt: null, loss: null, target: null };
  const stats = await pc.getStats();
  for (const r of stats.values()) {
    // GCC's send-side estimate lives only on the transport report
    if (r.type === 'transport' && r.availableOutgoingBitrate != null) {
      out.estimate = r.availableOutgoingBitrate;
    }
    // RTCP receiver reports surface here: the only sender-side view of loss and RTT
    if (r.type === 'remote-inbound-rtp' && r.kind === 'video') {
      out.loss = r.fractionLost ?? null;                              // 0..1
      out.rtt = r.roundTripTime != null ? r.roundTripTime * 1000 : null; // ms
    }
    // Proves the encoder is actually filling its ceiling before we probe
    if (r.type === 'outbound-rtp' && r.kind === 'video') {
      out.target = r.targetBitrate ?? null;
    }
  }
  return out;
}

async function writeCeiling(sender, bps) {
  const params = sender.getParameters(); // fresh transactionId on every write
  params.encodings[0].maxBitrate = Math.round(bps);
  await sender.setParameters(params);    // resolution deliberately untouched here
}

async function revert(sender, now, reason) {
  await writeCeiling(sender, committedCeiling); // hand the clamp straight back
  cooldown = Math.min(cooldown * 2, P.maxCooldownMs); // punish repeated failures
  nextProbeAt = now + cooldown;
  streak = 0;
  state = 'STEADY';
  console.warn('ramp reverted:', reason, 'next probe in', cooldown, 'ms');
}

async function rampTick(pc, sender) {
  if (pc.connectionState !== 'connected') return; // Safari throws otherwise
  const now = performance.now();
  const s = await sample(pc);
  if (s.estimate == null) return;

  if (state === 'STEADY') {
    if (now < nextProbeAt || committedCeiling >= TOP_CEILING) return;
    // Only probe when the encoder is genuinely pressed against its ceiling;
    // a static screen share sends far below it and can never "follow" a probe.
    if (s.estimate < committedCeiling * 0.95) return;
    if (s.target != null && s.target < committedCeiling * 0.85) return;
    baseline = s;
    probedCeiling = Math.min(committedCeiling * P.stepFactor, TOP_CEILING);
    await writeCeiling(sender, probedCeiling); // open headroom, do not force bits
    probeOpenedAt = now;
    state = 'PROBING';
    return;
  }

  if (state === 'PROBING') {
    const rttUp = s.rtt != null && baseline.rtt != null &&
                  (s.rtt - baseline.rtt) > P.rttGuardMs;
    const lossUp = s.loss != null && s.loss > P.lossGuard;
    if (rttUp || lossUp) return revert(sender, now, rttUp ? 'rtt-rise' : 'loss-rise');
    if (now - probeOpenedAt < P.confirmMs) return; // still inside the watch window
    if (s.estimate < probedCeiling * P.followRatio) {
      return revert(sender, now, 'no-follow'); // headroom did not exist
    }
    committedCeiling = probedCeiling; // the estimate came with us: keep it
    cooldown = P.cooldownMs;          // success resets the backoff
    nextProbeAt = now + cooldown;
    streak += 1;
    state = 'STEADY';
    if (streak >= P.scaleHold && currentScale > 1) {
      const params = sender.getParameters();
      currentScale = currentScale / 2;                       // 4 -> 2 -> 1
      params.encodings[0].scaleResolutionDownBy = currentScale;
      await sender.setParameters(params); // costs one keyframe; spend it rarely
      streak = 0;                         // re-earn the next resolution step
    }
  }
}

const videoSender = pc.getSenders().find(s => s.track?.kind === 'video');
setInterval(() => rampTick(pc, videoSender), 1000); // same 1 s poll as the down path

Four states are visible in that code, and it helps to see them as a machine rather than as branches:

Probe, confirm, commit or revert state machine STEADY waits for the cooldown, then opens a probe by raising the ceiling. PROBING holds three to five seconds while watching estimate, RTT and loss. A followed estimate commits the new ceiling and returns to STEADY; a rise in RTT or loss reverts the ceiling and doubles the cooldown. STEADY ceiling = committed PROBE OPEN ceiling x1.4 headroom CONFIRM watch est, RTT, loss REVERT restore, back off entered after a down-step cooldown elapsed hold 3-5 s commit: estimate followed 90% RTT +20 ms or loss > 2% cooldown doubles: 5 to 60 s
Recovery as an explicit state machine: every up-step is a retractable hypothesis with a watch window.

Reproduction Steps & Debugging Log Patterns

  1. Establish a call and shape the uplink hard: tc qdisc add dev eth0 root netem rate 400kbit. Let the existing down-step path settle, then confirm the recovery controller is parked in STEADY with committedCeiling at 400 kbps.
  2. Add one log line per tick β€” console.log(state, Math.round(s.estimate/1000)+'k', s.rtt+'ms', s.loss) β€” so probe boundaries are visible in the transcript rather than inferred.
  3. Remove the shaper with tc qdisc del dev eth0 root and watch the staircase form. A healthy run looks like this:
// STEADY  392k  41ms  0.001   ceiling 400k, cooldown expired
// PROBE   398k  42ms  0.001   ceiling raised to 560k
// PROBE   511k  43ms  0.000   estimate climbing into the headroom
// STEADY  554k  42ms  0.000   committed 560k (followed 99%), cooldown 5000
// PROBE   561k  43ms  0.001   ceiling raised to 784k
// STEADY  771k  44ms  0.000   committed 784k, streak 2 -> scale 2 to 1
  1. Now force the failure path: re-shape to a genuine hard limit with tc qdisc add dev eth0 root netem rate 700kbit. The first probe past 700 kbps must fail cleanly and the cooldown must double rather than retry immediately:
// PROBE   692k  45ms  0.002   ceiling raised to 980k
// PROBE   701k  61ms  0.014   RTT +16ms, loss 1.4% β€” still inside guards
// ramp reverted: no-follow next probe in 10000 ms
// ramp reverted: no-follow next probe in 20000 ms
// ramp reverted: no-follow next probe in 40000 ms   // settles at 700k, probes go quiet
  1. Finally test a marginal link β€” netem rate 1200kbit delay 40ms 10ms β€” and verify that committedCeiling reaches a value and stops moving. If it flips between two values every few seconds, the confirm window is too short relative to the feedback cadence; lengthen confirm_ms before touching step_factor.

Reading the raw counters correctly matters more than reading them often, and the field-by-field detail is in Interpreting getStats() for Congestion Signals. During a probe there are only four outcomes worth distinguishing, and each maps to exactly one action:

Probe outcome decision matrix Four rows map combinations of estimate behaviour, round-trip-time delta and fractional loss during the confirm window to a verdict: commit, revert with doubled cooldown, revert and hold thirty seconds, or step down and freeze the ramp for sixty seconds. Estimate in window RTT delta Loss Verdict and action reaches 90% of ceiling under +10 ms under 2% Commit, reset cooldown to 5 s flat at the old value under +10 ms under 2% No headroom: revert, cooldown x2 rises then sags back +30 to +80 ms under 2% Queue building: revert, hold 30 s falls below baseline over +80 ms over 5% Overshoot: step down, freeze 60 s All three columns are measured against the pre-probe baseline captured when the ceiling was raised.
Four probe outcomes, four distinct actions β€” a rising estimate alone is never sufficient evidence.

In chrome://webrtc-internals the same behaviour is visible without instrumentation: availableOutgoingBitrate traces a staircase rather than a ramp, and each riser is preceded by a short spike in packets sent per second as the browser pushes padding into the headroom you opened.

Common Implementation Mistakes

FAQ

Why does the estimator increase so much more slowly than it decreases?

Because the two directions have different risk profiles. Backing off too far costs a few seconds of reduced quality; probing upward too aggressively fills a bottleneck queue and inflicts delay on every flow sharing it, including your own. GCC encodes that asymmetry directly: a multiplicative 0.85 cut on a single overuse signal, versus additive growth of about one packet per RTT + 100 ms once it is near a rate it has previously seen fail.

Can I trigger a bandwidth probe from JavaScript?

Not directly β€” there is no probe API, and padding generation is the browser’s decision. What you can do is create the conditions for it: raise maxBitrate above the current estimate so the sender is no longer clamped, keep the encoder supplied with real content, and let the browser’s padding fill the gap. On links that stay unstable rather than simply recovering, pair this with the estimator tuning in Tuning WebRTC Bandwidth Estimator for Unstable Networks.

How long should I wait after a congestion event before the first probe?

Give the estimator 3–5 seconds of quiet first. Probing while the delay signal is still elevated produces an immediate revert and starts the backoff doubling from a false negative, which then delays the genuine recovery by tens of seconds. One deliberate probe after a settled interval beats three hurried ones.

Related: this walkthrough sits under Adaptive Bitrate Streaming in WebRTC; read it alongside the downward half in Reacting to Bandwidth Drops with RTCRtpSender Parameters and the quality-sacrifice policy in degradationPreference: Resolution vs Framerate.