Skip to content
WebRTC.wiki

WebRTC bitrate stuck low? maxBitrate is only a ceiling

You set maxBitrate to 2.5 Mbps and WebRTC sends 700 kbps. The congestion controller picks the rate. How GCC decides, and which getStats fields show why.

6 min readupdated

You set maxBitrate to 2,500,000. The call sends 700 kbps. Both machines are on fibre, iperf says there is 900 Mbps between them, and the video still looks like 2011.

Nothing is broken and nothing is misconfigured. maxBitrate on RTCRtpSender.setParameters() is the most WebRTC will let you send. How much you actually send is decided by the browser’s congestion controller, which you never configured and cannot turn off.

Google Congestion Control picks the bitrate

A congestion controller on the sending side. In every browser that is Google Congestion Control, fed by transport-cc feedback listing the arrival time of every packet you sent. GCC runs two estimators at once and, per section 6 of the draft, “the actual sending rate is set as the minimum between As_hat and A_hat”.

The loss-based half is simple arithmetic on the loss ratio. Under 2% loss it multiplies the estimate by 1.05. Between 2% and 10% it changes nothing. Above 10% it cuts to (1 - 0.5p) of what it was, so 20% loss costs you 10% of your bitrate per interval.

The delay-based half is the interesting one. It ignores loss entirely and watches the gradient between when packets were sent and when they arrived. Queues build before routers drop anything, so a growing one-way delay is congestion arriving early enough to react to. When the detector fires, the estimate drops to 0.85 of the rate the receiver was measuring, not 0.85 of what you were sending, which are different numbers when a queue is draining.

Neither half reads maxBitrate. The estimate is produced first and clamped afterwards.

Why every call starts at 300 kbps

That is the default in libwebrtc, and every call begins there regardless of what you configured or what the last call on the same network managed. From there, growth is capped at 8% per second, using 1.08^min(time_since_last_update_ms / 1000, 1.0). Getting from 300 kbps to 2.5 Mbps on that curve alone takes about 28 seconds.

It is usually faster than that, because the sender probes. libwebrtc fires two exponential probes at start, at 3x and 6x the start bitrate, so it takes a run at roughly 900 kbps and 1.8 Mbps immediately rather than crawling. Probing is also why raising maxBitrate mid-call is not quite a no-op: the probe controller starts a new probe when the new maximum is above both the old maximum and the current estimate. Raising the ceiling gives the estimator permission to go looking. It does not hand the encoder anything.

The asymmetry is worth internalising. Lowering maxBitrate takes effect on the next frame. Raising it starts a negotiation with the network that takes seconds and may lose.

Once the estimate is near where it was before, GCC switches from multiplicative to additive increase, adding at most half a packet per 100 + rtt milliseconds. On a 100ms link that is roughly 6 kbps per second. If you have ever watched a call recover from a bad minute at a pace that felt insulting, that is the number you were watching.

Find the bottleneck with getStats

Three fields answer it, and they live in different places.

const stats = await pc.getStats();
for (const s of stats.values()) {
  if (s.type === 'candidate-pair' && s.nominated) {
    console.log('estimate', s.availableOutgoingBitrate);
  }
  if (s.type === 'outbound-rtp' && s.kind === 'video') {
    console.log(s.rid ?? 'single', {
      target: s.targetBitrate,
      reason: s.qualityLimitationReason,
      durations: s.qualityLimitationDurations,
      resolutionDrops: s.qualityLimitationResolutionChanges,
    });
  }
}

availableOutgoingBitrate is the estimate itself, computed over a one second window, and it does not include IP or UDP overhead, so it is not what your interface counter sees.

qualityLimitationReason is one of none, cpu, bandwidth and other. Read it as a pointer to whose problem this is. bandwidth means the estimator is the cap and no encoder setting will help. cpu means the machine is, and lowering the bitrate is treating the wrong end. none means you are getting everything you asked for and the ceiling you set is the actual limit, which is the only case where changing maxBitrate does what you expected.

Log qualityLimitationDurations rather than the reason. The reason is whatever was true at the instant you polled. The durations are cumulative seconds per reason across the whole call, so at hangup you get a single object that says this call spent 240 seconds bandwidth-limited and 12 seconds CPU-limited, and there is nothing left to argue about.

Audio, video and simulcast share one estimate

BUNDLE puts audio, video, data channels and retransmissions on one transport, and the estimate belongs to that transport. Audio gets served first because it is small and matters most. Then video, then everything else.

So targetBitrate on your video stream is always lower than availableOutgoingBitrate, and the gap is real traffic, not slack. Watch retransmittedBytesSent and headerBytesSent on the outbound stream if you want to see where it went. On a lossy link this is the second bite: loss cuts the estimate, and then NACK repair spends part of what is left resending packets instead of sending new ones.

Simulcast splits the same pot again. Three layers do not get three budgets, they get one, allocated bottom-up. When the estimate falls, the top layer is dropped entirely rather than starved, which is why a viewer watching high can lose the stream while the estimate still looks survivable.

What you can change: degradationPreference and contentHint

Not the estimate. There is no API for it, deliberately, and that is correct. What you can change is what the encoder does with whatever it gets.

degradationPreference on the send parameters decides what gets sacrificed. maintain-framerate keeps the motion and drops resolution, which is what you want for anything with movement in it. maintain-resolution keeps the pixels and drops the frame rate, which is what you want for a screen share of text. balanced splits the difference, and qualityLimitationResolutionChanges tells you how often it has already made that call.

const params = sender.getParameters();
params.degradationPreference = 'maintain-resolution';
await sender.setParameters(params);

contentHint on the track does something similar one level up, and it is a single assignment: track.contentHint = 'detail' for a screen share, 'motion' for a camera. Nobody sets it.

Beyond that, the only remaining levers are non-standard fmtp parameters. x-google-start-bitrate and x-google-min-bitrate will move the starting point and the floor in Chromium, and they arrive by editing the SDP, which puts you in the territory the munging article is about. They also have a long history of being quietly ignored depending on where in the description you put them. If your product is a broadcast from a server you control on a link you have measured, that is a defensible bet. On a consumer call it is a way to overshoot a bad network faster.

transport-cc is still an expired draft

The feedback that all of this runs on is draft-holmer-rmcat-transport-wide-cc-extensions-01, which expired in 2016. RFC 8888 standardised the same idea in 2021, per SSRC rather than per transport, with room for ECN marks. Browsers ship the expired draft.

What to monitor instead of peak bitrate

Not peak bitrate. Peak bitrate tells you about the best moment of the call. Alert on the ratio of bandwidth-limited seconds to call duration, from qualityLimitationDurations, and you will find out which networks your users are actually on. My experience is that the answer is worse than the dashboard suggests, because the dashboard is averaging over the office wifi of the people who built it.