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.

GCC bandwidth estimation feedback loop RTP packets flow from sender to receiver. The receiver timestamps arrivals and emits transport-wide-cc RTCP feedback to the sender. GCC's delay-based and loss-based controllers fuse into a target bitrate that sets the encoder ceiling, closing the loop. Sender GCC controllers + pacer Encoder maxBitrate ceiling Receiver arrival timestamps per packet RTP media + abs-send-time RTCP transport-wide-cc feedback target bitrate → ceiling delay + loss fused estimate
The GCC feedback loop: the receiver measures arrivals, transport-wide-cc carries the timing back, and the fused delay/loss estimate sets the encoder ceiling.

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.

Delay-based rate controller state machine Three states — increase, hold and decrease — driven by the trendline over-use detector. A normal slope moves the controller toward increase where it probes upward by about eight percent per RTT, an over-use signal forces decrease which cuts the estimate to roughly 85 percent of the measured receive rate, and under-use parks the controller in hold while the queue drains. Trendline signal drives the rate-control state signal sampled per packet group: normal / over-use / under-use Increase probe upward Hold estimate frozen Decrease back off fast normal slope under-use over-use slope back to normal over-use held ~10 ms — cut immediately, skipping hold +8% per RTT near last estimate no change queue drains ×0.85 receive rate not ×0.85 of target
The delay-based controller only ever moves between three states; the adaptive threshold decides how much slope counts as over-use before the cut fires.

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.

Loss-based controller decision tree A measured loss fraction is sorted into three bands. Below two percent the loss estimate grows by eight percent, between two and ten percent it holds, and above ten percent it is multiplied by one minus half the loss fraction. All three outcomes feed a final node where the target bitrate is the minimum of the loss estimate and the delay estimate. How the loss fraction is turned into a bitrate action fractionLost from RTCP sampled every ~1 s loss < 2% 2% ≤ loss ≤ 10% loss > 10% estimate × 1.08 treated as noise hold estimate NACK and FEC absorb × (1 − 0.5 × loss) 20% loss ≈ halved target = min(loss estimate, delay estimate)
Three loss bands, three actions — and the more pessimistic of the loss and delay estimates always becomes the sending target.

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.

RTCP transport-wide-cc feedback packet layout A stacked 32-bit word layout of the transport-cc feedback message: RTCP header with FMT 15 and payload type 205, sender SSRC, media source SSRC, base sequence number with packet status count, 24-bit reference time with feedback packet count, packet status chunks, and the receive delta list in 250 microsecond ticks. Notes on the right explain what the sender does with each field. Inside one transport-wide-cc feedback packet bit 0 8 16 24 31 V=2 P FMT=15 PT=205 length RTCP header — identifies transport-cc SSRC of packet sender SSRC of media source routes feedback to the right stream base sequence number packet status count which packets this report covers reference time (24 bits, 64 ms ticks) fb pkt count anchors the arrival timeline packet status chunks run-length or status-vector: received / lost exact loss pattern, not a rounded loss fraction receive delta list 8 or 16 bit deltas, 250 µs resolution this is what the trendline filter actually consumes REMB replaces everything below the header with one aggregate bitrate value
Field layout of a transport-cc feedback report: the status chunks and delta list are the raw material GCC needs, and they are exactly what REMB throws away.

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

Common Implementation Mistakes

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.