Bandwidth Estimation & Congestion Control
Real-time media delivery lives or dies on a single feedback loop: the receiver measures how packets arrive, the sender turns those measurements into a target bitrate, and the encoder obeys that target before the next group of pictures is produced. Get the loop right and a 1080p call holds sub-200 ms latency through a Wi-Fi-to-cellular handoff; get it wrong and the connection either floods the buffer until frames arrive seconds late, or starves the encoder into a pixelated 200 kbps slideshow on a link that could have carried ten times that. This guide is part of the Media Handling, Codecs & Bandwidth Estimation guide, and it covers the exact mechanics of Google Congestion Control (GCC), the transport feedback that drives it, and how the encoder reacts — across Chrome, Firefox, and Safari.
The goal is concrete: implement and verify a closed-loop estimator that converges to available capacity within 200–500 ms, holds steady under bursty radio scheduling, and degrades gracefully instead of oscillating. Everything below assumes you negotiate transport-wide feedback, read availableOutgoingBitrate from the right report, and treat setParameters() as a slow control signal rather than a per-frame knob.
Step 1 — Run the GCC delay-based controller
The primary estimator in modern WebRTC is delay-based. The receiver records the arrival time of every RTP packet (using the abs-send-time or transport sequence-number header extension) and the sender computes the inter-group delay gradient — how much later each group of packets arrives than the previous one, relative to its send spacing. A Trendline Filter smooths this gradient over a sliding window of recent packet groups and compares the slope against an adaptive threshold. A consistently rising delay means a queue is building somewhere on the path, which the controller treats as the leading indicator of congestion — well before any packet is actually dropped.
The controller runs a small state machine over the trendline output: hold, increase, and decrease. While the gradient sits near zero it multiplicatively probes upward (roughly +8% per RTT in the additive-increase region near the last estimate); when the slope crosses the over-use threshold it switches to decrease and cuts the estimate to about 85% of the current receive rate. The adaptive threshold itself widens when the network is jittery, which is what stops a clean delay-based controller from panicking on every micro-spike — and exactly the behaviour you want to preserve when tuning the WebRTC bandwidth estimator for unstable networks, where bursty 4G/5G scheduling looks like queue growth but isn’t.
Two timing details matter for anyone reasoning about why the estimate moves the way it does. First, the trendline operates on packet groups, not individual packets — packets sent within a few milliseconds of each other are bundled so transient pacing noise averages out before the filter sees it. Second, the over-use detector requires the slope to stay above threshold for a minimum duration (around 10 ms of accumulated over-use, or several consecutive samples) before it fires, which is why a single late packet never triggers a cut but a genuine standing queue does. The cold-start estimate begins near 300 kbps and ramps; if you see a session that never climbs above that floor, the loop is not receiving feedback at all, not “estimating conservatively.”
You do not implement the Trendline Filter yourself — it lives inside libwebrtc — but you must feed it. The single most important application action is negotiating the feedback extension so the loop has data to run on:
// The sender's offer must carry the transport-wide-cc header extension.
// Without it GCC has no per-packet arrival data and silently falls back to REMB.
const offer = await pc.createOffer();
// Confirm the extension is present before setLocalDescription:
const hasTwcc = /transport-wide-cc/.test(offer.sdp); // expect true
if (!hasTwcc) {
console.warn('transport-wide-cc absent — estimator will run loss-only');
}
await pc.setLocalDescription(offer); // commit only after the check
Why the over-use threshold adapts
The most misunderstood part of the delay-based controller is that the over-use threshold is itself a state variable, not a constant. Every time a new trendline sample arrives, the threshold moves toward the absolute value of that sample with an exponential update whose gain differs by direction — roughly 0.0087 when the threshold must grow and 0.039 when it must shrink. Tolerance is therefore earned slowly and lost quickly. A link that has been jittery for a minute widens the band far enough to ignore radio-scheduling spikes, while a link that settles pulls the band back in within a few hundred milliseconds and the controller becomes twitchy again.
That asymmetry exists for one reason: coexistence with loss-based TCP. A fixed, narrow threshold would make GCC the politest flow on the bottleneck. Every time a parallel download fills the queue, the media stream cuts; TCP takes the freed capacity, refills the queue, and GCC cuts again — ratcheting video toward the 300 kbps floor while the download runs at line rate. Letting the threshold widen under sustained delay noise breaks that ratchet and lets the media flow hold a share of the link instead of being squeezed out of it.
The practical consequence is that estimator behaviour is not stationary across a call: comparing a repro captured in the first ten seconds against one captured at minute ten is comparing two differently-tuned controllers, and the later one is deliberately less reactive.
Step 2 — Layer the loss-based controller on top
The delay-based estimate is necessary but not sufficient. On paths with shallow buffers — many DOCSIS uplinks, some LTE bearers — packets are dropped before queueing delay grows enough for the trendline to react. GCC therefore runs a second, loss-based controller in parallel and takes the minimum of the two estimates as the final target.
The loss controller is intentionally coarse. The classic rule: if the fraction lost is below 2%, increase the estimate by 8%; if it sits between 2% and 10%, hold; if it exceeds 10%, multiply the estimate by (1 − 0.5 × lossFraction). So at 20% loss the estimate is roughly halved. The asymmetry is deliberate — small loss is treated as noise (FEC and NACK absorb it), while sustained heavy loss is the only signal trusted enough to override a calm delay reading. Newer Chrome builds ship a LossBasedBweV2 that probes more aggressively and is enabled by field trial; the thresholds above are the stable baseline you should reason about.
Because the controllers fuse by taking the minimum, your application’s job is to not lie to either one. Disabling NACK or FEC, for example, makes retransmission gaps look like real loss and makes recovered packets look like late arrivals — both controllers then over-react. Keep loss recovery on, and let the loss controller see the genuine residual loss rate, not an artifact of your own configuration — the same logic that governs tuning Opus bitrate and FEC for lossy networks on the audio side, where in-band FEC costs a few kbps and buys back the packets that would otherwise read as congestion.
There is a subtler interaction worth internalising: the two controllers can disagree, and the minimum-takes-all rule means whichever is more pessimistic wins. On a deep-buffer path (bufferbloat), the delay-based controller backs off early — queueing delay grows long before any drop — so it dominates and the loss controller barely registers. On a shallow-buffer path the reverse holds: drops arrive before delay grows, so the loss controller is the one cutting. Knowing which controller is driving tells you where to look: a falling estimate with rising RTT but no loss is the delay path reacting to a queue; a falling estimate with rising loss but flat RTT is the loss path reacting to drops. The same getStats fields covered later disambiguate this directly.
Step 3 — Carry the estimate back: REMB vs transport-cc
Two RTCP mechanisms historically delivered the feedback. REMB (Receiver Estimated Maximum Bitrate) runs GCC on the receiver and ships a single aggregate bitrate number back to the sender. Transport-wide congestion control (transport-cc) instead ships raw per-packet arrival timestamps back and runs GCC on the sender. Transport-cc won because per-packet data lets the sender attribute delay to specific packet bursts, run the trendline at full resolution, and react in 50–100 ms instead of waiting for a coarse aggregate.
| Mechanism | Where GCC runs | Feedback payload | Reaction time | Status |
|---|---|---|---|---|
| REMB | Receiver | One aggregate bitrate (bps) | ~1 s coarse | Legacy; ignored when transport-cc is negotiated |
| transport-cc | Sender | Per-packet arrival deltas | 50–100 ms | Default in modern Chrome/Firefox |
The “per-packet arrival deltas” row is where the resolution advantage lives. A transport-cc feedback packet is an RTCP payload-specific message (PT 205, FMT 15) that names a base sequence number, states how many packets it covers, and then carries a compressed received/lost bitmap plus a list of arrival deltas in 250 µs ticks — enough for the sender to reconstruct the receiver’s arrival timeline packet by packet rather than trusting someone else’s summary.
In practice you negotiate transport-cc and verify REMB is inert. If both appear in the SDP, modern Chrome and Firefox prefer transport-cc and REMB has no effect — so chasing REMB behaviour in a transport-cc session wastes hours; the line-by-line SDP evidence for which one is actually driving the estimate is laid out in transport-CC vs REMB feedback. Set the RTCP feedback cadence so transport-cc reports flow every 50–100 ms; longer intervals starve the trendline and the estimate lags real capacity by seconds, which is the same lag you fight after a network handoff. Validate the negotiated extension in chrome://webrtc-internals → Transport, and confirm no intermediary (a TURN relay, a corporate firewall) is stripping RTP header extensions, which silently disables the whole loop.
When the feedback path itself is the congested one
Everything above assumes the reports arrive. They do not travel on a separate channel — they share the same 5-tuple as the media flowing the other way, so on an asymmetric access link the feedback for your downstream video queues behind that peer’s own upstream video. Two distinct failures follow, and they look nothing alike.
If reports are merely delayed, the sender’s trendline consumes arrival deltas describing a network state several hundred milliseconds in the past. The controller becomes a lagged feedback system: it cuts after the queue has already drained, then probes back into a queue that has already rebuilt, and the estimate develops a sawtooth whose period tracks the round-trip time rather than anything happening on the forward path. If reports are lost outright, the sender sees a discontinuity in the feedback packet count field — that counter exists precisely so the gap is detectable — and after roughly a second with no feedback at all it parks the estimate rather than continuing to probe blind.
Telling the two apart takes one graph. In chrome://webrtc-internals, overlay the estimate on the RTT of the same transport: if the oscillation period matches RTT and the forward-path loss is flat, you are looking at lagged feedback, not congestion. The fix lives on the receiving peer — cap its outgoing bitrate so its uplink queue stays shallow, and make sure RTCP is not stuck behind a deep pacer queue there. The overhead you are protecting is cheap: at one report every 50–100 ms, transport-cc costs on the order of 10–30 kbps, which is worth paying even on a constrained uplink.
Step 4 — Verification: make the encoder react and confirm it
The estimate is worthless unless the encoder obeys it. The sender’s pacer already throttles transmission to the target, but the encoder’s production rate must follow too, or the pacer queue grows and you get buffer bloat. WebRTC drives the encoder automatically from the GCC target; your job is to set a sane ceiling and verify convergence, not to micromanage the bitrate frame by frame.
// Set a ceiling once; let GCC and the pacer allocate within it.
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const params = sender.getParameters();
if (!params.encodings?.length) params.encodings = [{}];
params.encodings[0].maxBitrate = 2_500_000; // 2.5 Mbps headroom, not a target
await sender.setParameters(params); // a control signal — call at most every 3–5 s
// Verify the loop converged: target should track availableOutgoingBitrate.
const stats = await pc.getStats();
let availBps = null, targetBps = null;
for (const r of stats.values()) {
if (r.type === 'transport') availBps = r.availableOutgoingBitrate ?? null; // estimate
if (r.type === 'outbound-rtp' && r.kind === 'video') targetBps = r.targetBitrate ?? null; // encoder follows
}
// Healthy loop: targetBps converges toward availBps within 200–500 ms of a step change.
console.log(`estimate=${availBps} bps, encoder target=${targetBps} bps`);
Read availableOutgoingBitrate from the transport report — never inbound-rtp — and confirm targetBitrate on outbound-rtp tracks it. If the target plateaus well below the estimate, the encoder is the bottleneck (CPU or a maxBitrate set too low), not the network. Convergence is also asymmetric: the cut lands within one feedback interval but the climb back is the slow half of the loop, and the recovery pacing worth copying is described in ramping bitrate back up after congestion. The full menu of which fields signal congestion versus encoder overload is covered in interpreting getStats() for congestion signals. Reading those stats correctly also feeds adaptive bitrate streaming in WebRTC, where the same estimate drives simulcast layer toggling.
How the fused target is split across encodings
GCC produces one number per transport, but a simulcast sender has three encodings and an audio track competing for it. The bitrate allocator sits in between, and it does not divide the target proportionally. It fills in priority order, lowest layer first: audio takes its Opus allocation (24–32 kbps for typical voice), the quarter-scale layer takes its minimum, then the half-scale layer, and the full-resolution layer receives whatever remains. Crucially, a layer whose minimum cannot be met is not sent at a degraded rate — it is switched off entirely, and its outbound-rtp report simply stops advancing bytesSent.
That all-or-nothing rule is why capacity loss shows up as a cliff rather than a slope. Push the estimate below the sum of audio plus the two lower layers’ minimums and the top layer disappears within a single allocation cycle; every subscriber pinned to it freezes until the forwarder switches them down, which is the coordination problem addressed in Bandwidth-Aware Layer Selection in an SFU. Inside a surviving layer the encoder still has a choice about how to spend a shrinking budget — fewer pixels or fewer frames per second — and that choice belongs to you rather than to the estimator, via degradationPreference: Resolution vs Framerate.
One sanity check catches most misconfigured senders: sum your per-encoding maxBitrate values and compare against the availableOutgoingBitrate you observe on a known-good link. If the sum never fits, the top layer is decorative — allocated at call start, dropped on the first correction, never seen again.
Probing: how the estimate climbs faster than +8% per RTT
The +8%-per-RTT figure describes steady-state increase only. At call start and after a large backoff, GCC does not wait for multiplicative increase to crawl back — it emits explicit probe clusters. The pacer sends a short burst at a multiple of the current estimate, typically 3× at startup and around 2× after a cut, padding with dummy packets when the encoder cannot produce enough bytes on its own, and measures the rate at which that burst is received. A probe burst that arrives intact at 3× moves the estimate straight there in one round trip, instead of the dozen-plus RTTs that +8% steps would need to cover the same ground.
Probing only works when there is something to send. In the application-limited region — a screen share of a motionless slide, a camera pointed at a still scene — the encoder emits almost nothing, no burst can be measured, and the estimate freezes at whatever value was current when the content went static. The moment the content moves again the sender is clamped by that stale number and the first active frames arrive late. Padding-based probing during application-limited periods exists specifically to keep the measurement alive through those quiet stretches; native builds can tune it, browsers cannot:
; libwebrtc field trials — native builds only, passed via --force-fieldtrials
; keep probing while the encoder is application-limited (static screen share)
WebRTC-Bwe-AlrProbing/Enabled/
; probe multipliers: aggressive at call start, gentler after a backoff
WebRTC-Bwe-ProbingConfiguration/probe_at_start:3.0,probe_after_drop:2.0/
; cap padding so probes never cost more than a fraction of the estimate
WebRTC-Bwe-MaxPaddingFraction/fraction:0.10/
Edge Cases & Browser Quirks
- Chrome concurrent hardware encoders. Chrome caps simultaneous hardware encoder sessions per tab. When the GPU encoder queue saturates,
availableOutgoingBitratecan fall even on a perfectly healthy link — the limit is silicon, not the network. Cross-checkqualityLimitationReason(it reportscpu) before you blame congestion. - Firefox estimate exposure. Firefox historically did not surface
availableOutgoingBitrateon thetransportreport the way Chrome does, and exposes the estimate inconsistently across versions. Build dashboards that tolerate anulland fall back to deriving headroom fromtargetBitratevsbytesSenttrends. - Safari clamps mid-call setParameters. Safari limits
maxBitratechanges while media is actively flowing and will quietly ignore a ceiling change unless the connection is in theconnectedstate. Apply ceilings onconnectionstatechangereachingconnected, not opportunistically. - Header-extension stripping. Some TURN relays and middleboxes strip unknown RTP header extensions. If
transport-wide-ccis in your SDP but the estimator never moves off its 300 kbps start value, capture with Wireshark and confirm the extension survives the relay — this is the most common cause of an estimator that “doesn’t work.” - Cellular handoff lag. After a Wi-Fi-to-LTE handoff the estimate can lag real capacity for several seconds while GCC re-probes from a conservative floor. Request a keyframe (PLI) to reset decoder state during the re-probe rather than waiting blindly, and coordinate the re-probe with the transport-level work in handling Wi-Fi to cellular network handover, since a fresh candidate pair and a fresh estimate arrive at different times.
- Screen-share minimum bitrate floors. With
contentHint: 'detail'set on a screen track, Chrome raises that encoder’s minimum bitrate to keep text legible. The allocator honours the floor, so it will disable a camera layer outright rather than shave the screen share below it. On a 600 kbps estimate you can end up with a crisp slide and no camera at all — correct behaviour, but surprising if you expected both streams to degrade together. - Probe padding inflates early byte counters.
bytesSentonoutbound-rtpincludes the padding packets used for probe clusters, so a dashboard that derives sending bitrate from byte deltas shows a spike in the first two or three seconds that the encoder never actually produced. During startup, trusttargetBitrateover byte-rate arithmetic.
Common Implementation Mistakes
- Reading
availableOutgoingBitratefrominbound-rtp. It lives on thetransportreport. This single mistake produces dashboards that showundefinedand “broken” estimators that are working fine. - Hardcoding a bitrate cap that ignores feedback. A static
maxBitratefar below capacity caps the probe and GCC can never climb past it; far above capacity and the pacer queue bloats. Set headroom, not a target. - Never negotiating transport-cc. Omit the extension and the estimator silently runs loss-only — coarse, slow, and blind to queue growth before drops. Always verify it survives into the negotiated SDP.
- Disabling NACK/FEC to “save bandwidth.” This poisons both controllers: retransmission gaps read as loss, recovered packets read as late arrivals, and the estimate collapses.
- Calling
setParameters()in a tight loop. It is async; a per-frame PID loop serialises poorly and creates encoder-state inconsistency. Treat it as a control signal updated every 3–5 seconds after conditions stabilise. - Confusing CPU overload with congestion. High jitter with under 2% loss and a stalled
targetBitrateis almost always the encoder. CheckqualityLimitationReasonandtotalEncodeTimebefore cutting bitrate. - Filing a bug against a frozen estimate on static content. An estimate that stops moving while the shared screen shows a motionless slide is the application-limited case, not a stuck controller. Confirm
framesEncodedis barely advancing before you go looking for a network fault. - Tearing down the peer connection to “clear” a low estimate. A rebuild throws away the trendline window and the adapted threshold and restarts from the 300 kbps cold-start floor, on top of paying for fresh ICE. Probing recovers a depressed estimate far faster than a reconnect does.
FAQ
Should I disable REMB in modern deployments?
You do not need to. When transport-cc is negotiated, modern Chrome and Firefox prefer it and REMB has no effect on the running estimate. Verify transport-cc is active in chrome://webrtc-internals before spending any time debugging REMB — in a transport-cc session, REMB is inert and chasing it is wasted effort.
How does GCC tell network congestion apart from CPU overload?
It doesn’t directly — you do, from stats. GCC reacts to inter-arrival delay and loss. If delay rises with no corresponding loss and outbound-rtp.totalEncodeTime is high while qualityLimitationReason reports cpu, the bottleneck is the encoder, not the path. Reduce layer count or resolution rather than fighting the estimator.
Why does my estimate climb so slowly after it drops? GCC increase is deliberately conservative — additive probing of roughly +8% per RTT near the last estimate — to avoid re-flooding a path it just backed off. On high-RTT links this looks sluggish. It is working as designed; the protection against overshoot is worth the slower recovery.
What feedback cadence should transport-cc use? Aim for one feedback packet every 50–100 ms. Slower than that and the trendline runs on stale data, so the estimate trails real capacity by seconds; faster wastes uplink on RTCP without improving granularity.
Does the estimate survive an ICE restart or a network change? No. The estimate, the trendline history, and the adapted over-use threshold are transport state, and a new candidate pair means a new path with different capacity, so keeping the old number would be actively wrong. Expect a cold start near 300 kbps followed by probe clusters, which is why the first two or three seconds after a handover look worse than the link deserves. Design the UI to ride that out rather than reacting to it.
Can the receiving side read the sender’s estimate?
Not from its own stats. With transport-cc the controller runs on the sender, and the receiver only ever sees inbound counters — jitter, loss, frames decoded — never the target the sender chose. If a receiving client needs the number, for a quality badge or a diagnostic overlay, publish it: sample availableOutgoingBitrate on the sender at 1 s intervals and forward it over a data channel or from your media server, which is also the cleanest place to aggregate it for alerting.
Related: this section sits under the Media Handling, Codecs & Bandwidth Estimation guide; for hands-on work see tuning the WebRTC bandwidth estimator for unstable networks and interpreting getStats() for congestion signals, and pair them with adaptive bitrate streaming in WebRTC and simulcast & SVC implementation.