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.

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

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.

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

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

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

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.

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.