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 |
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:
Reproduction Steps & Debugging Log Patterns
- 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 inSTEADYwithcommittedCeilingat 400 kbps. - 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. - Remove the shaper with
tc qdisc del dev eth0 rootand 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
- 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
- Finally test a marginal link β
netem rate 1200kbit delay 40ms 10msβ and verify thatcommittedCeilingreaches 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; lengthenconfirm_msbefore touchingstep_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:
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
- Leaving the ceiling clamped and waiting for the estimate to recover. Send-side estimation cannot discover capacity you never use, so a low
maxBitrateand a low estimate hold each other down indefinitely. Something must raise the ceiling speculatively. - Treating a rising estimate as proof of success. The estimate can climb for two or three seconds while a bottleneck queue fills; only a rise that comes without an RTT delta and without loss on
remote-inbound-rtpis real headroom. - Restoring bitrate and resolution in the same step. The resolution change forces a 30β60 kB keyframe on top of the new higher rate, which is often exactly the burst that re-triggers overuse. Gate resolution behind two clean bitrate commits, and pick the sacrifice order deliberately using degradationPreference: Resolution vs Framerate.
- Retrying failed probes on a fixed interval. A 5 s retry against a link that genuinely tops out at 700 kbps is a permanent low-grade congestion generator. Double the cooldown on every failure and cap it around 60 s.
- Probing while the source is the limit. A static screen share or a paused camera sends far below its ceiling, so no probe can ever be followed and the backoff grows for no reason. Require the estimate and
targetBitrateto be pressed against the current ceiling before opening a probe. - Sharing mutable state with the down-step path. If a drop fires mid-probe, the revert must not overwrite the newer, lower ceiling the down-step just wrote β clear the probe state first, as covered in Reacting to Bandwidth Drops with RTCRtpSender Parameters.
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.