Adaptive Bitrate Streaming in WebRTC with RTCRtpSender Parameters
Adaptive bitrate streaming in WebRTC is the practice of continuously matching what the encoder produces to what the network can actually carry, by reading the browser’s bandwidth estimate and reshaping the outbound video through RTCRtpSender.getParameters() and setParameters(). This guide is part of the Media Handling, Codecs & Bandwidth Estimation guide, and its goal is concrete: build a closed control loop that lowers bitrate and resolution the moment capacity drops, then ramps back up without oscillation, all from the publisher side without renegotiating the session.
The browser already runs Google Congestion Control and produces a usable availableOutgoingBitrate figure. Your job is not to reinvent estimation — it is to translate that figure into encoder ceilings, resolution scaling, and degradation policy fast enough that frozen frames and blockiness never reach the viewer. The five steps below cover the full surface: reading and writing sender parameters, capping per-encoding bitrate and downscaling resolution, reacting to the estimate, choosing a degradationPreference that fits the content, and verifying the loop actually closed.
This matters because the failure is asymmetric and unforgiving. When capacity drops and the encoder keeps emitting at its old ceiling, the pacer queue fills, round-trip time climbs, and the viewer sees a multi-second freeze followed by a keyframe flash — far worse than a clean, deliberate drop to a lower tier. WebRTC’s own controllers respond within 200–500 ms, but they govern only pacing and the encoder’s internal target; the resolution ladder and the tier policy are yours to drive. Done well, an application-layer loop polling at 1 s intervals adds negligible overhead while keeping perceived quality smooth across cellular handoffs, Wi-Fi contention, and congested uplinks.
Step 1 — Read and write sender parameters safely
Every adaptation begins by fetching the current parameters from the sender, mutating a field, and writing the whole object back. The contract is strict: you must pass back the exact object shape returned by getParameters(), including the opaque transactionId, or the browser rejects the call. Never construct an encodings array from scratch on an established sender — read, mutate, write.
// Locate the video sender on an established RTCPeerConnection
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
// getParameters() returns a live snapshot including a transactionId you must echo back
const params = sender.getParameters();
// Guard: on some platforms encodings can be empty until the first negotiation settles
if (!params.encodings || params.encodings.length === 0) {
params.encodings = [{}]; // single default encoding; browser fills the rest
}
// Mutate only writable fields, then persist the entire object
params.encodings[0].maxBitrate = 1_200_000; // 1.2 Mbps ceiling for this encoding
await sender.setParameters(params); // resolves once the encoder applies the change
setParameters() is asynchronous and returns a promise; await it so you do not stack overlapping writes. Treat a single in-flight write as a lock — issuing a second setParameters() before the first resolves is the most common source of InvalidStateError and dropped updates. Reading the underlying estimate that drives these writes is covered in depth by Bandwidth Estimation & Congestion Control, which explains how availableOutgoingBitrate is computed before you ever consume it.
Why this write reaches the encoder without touching SDP
The parameters object is a view onto the sender’s RTP-level configuration — precisely the part of the transceiver that offer/answer deliberately leaves unpinned. SDP negotiates what may be sent: codecs and payload types, header extensions, RID identifiers, direction, and the receiver’s own b=AS/b=TIAS limits. It never negotiates how many bits the encoder spends inside those constraints. That split is the whole reason a ceiling change is free on the wire — no new m-line, no fingerprint exchange, no ICE work, and the remote decoder’s configuration is untouched.
The transactionId is not a checksum; it is an optimistic-concurrency token. The browser mints a fresh one on every getParameters() call and accepts a write only if the id you echo is the most recent one issued, so that two independent writers in the same page — an adaptation loop and a user-facing quality picker, say — cannot silently overwrite each other. The loser of the race gets an InvalidStateError and is expected to re-read and re-apply, which is why caching the snapshot is a bug rather than an optimisation.
Only a small set of fields is writable: per encoding, active, maxBitrate, maxFramerate, scaleResolutionDownBy, and the priority hints; at the top level, degradationPreference. Adding an encoding, changing a rid, or reordering the array throws InvalidModificationError, because those are negotiated properties masquerading as ordinary object fields. Cost differs by field too: a maxBitrate change is absorbed by the rate controller on the next frame, whereas a scaleResolutionDownBy change forces an encoder reconfiguration and a fresh keyframe costing 3–8× an inter frame — a bandwidth spike arriving exactly when capacity is scarce, and the reason a ladder with a dozen resolution steps performs worse than one with four.
Step 2 — Cap bitrate with maxBitrate and downscale with scaleResolutionDownBy
Two knobs shape the encoder output per encoding. maxBitrate sets the ceiling the encoder may not exceed; the pacer and GCC still allocate freely below it. scaleResolutionDownBy divides the capture resolution before encoding — a value of 2 turns 1280×720 capture into 640×360 encode, cutting pixel count to a quarter and dramatically reducing the bits required for a sharp frame.
The pairing matters. Dropping maxBitrate alone forces the encoder to spend a shrinking budget on the same pixel count, producing blocky, smeared frames. Below roughly 500 kbps for 720p content, you should be reducing resolution, not just starving the bitrate. A practical ladder pairs the two so each tier stays visually coherent.
// Bitrate/resolution tiers; each keeps quality coherent at its bandwidth band
const TIERS = [
{ maxBitrate: 2_500_000, scaleResolutionDownBy: 1 }, // 720p full
{ maxBitrate: 1_200_000, scaleResolutionDownBy: 1 }, // 720p reduced
{ maxBitrate: 600_000, scaleResolutionDownBy: 2 }, // 360p
{ maxBitrate: 250_000, scaleResolutionDownBy: 4 } // 180p
];
async function applyTier(sender, tier) {
const params = sender.getParameters();
const enc = params.encodings[0];
enc.maxBitrate = tier.maxBitrate; // hard ceiling for the encoder
enc.scaleResolutionDownBy = tier.scaleResolutionDownBy; // downscale before encode
await sender.setParameters(params); // single atomic write of both knobs
}
The tier boundaries above are not arbitrary. The jump from scaleResolutionDownBy: 1 to 2 happens at 600 kbps because 720p below roughly 500–600 kbps cannot hold a sharp frame — the encoder spends its budget on macroblocks rather than detail, and a clean drop to 360p at the same bitrate looks markedly better. Keep a floor around 100–150 kbps so the encoder always has enough budget for a single keyframe; setting maxBitrate below that starves the encoder and produces stalls rather than a low-quality picture. Tune the exact thresholds to your content: motion-heavy camera video tolerates lower resolution better than static text, which benefits from holding resolution and shedding frame rate instead.
When you run simulcast, each entry in encodings carries its own maxBitrate and scaleResolutionDownBy, and you adapt by toggling active rather than rewriting a single stream. That multi-encoding model is the subject of Simulcast & SVC Implementation, which is the right tool once a media server is fanning your stream out to many subscribers.
The bits-per-pixel arithmetic behind the tier boundaries
The 600 kbps boundary is derived, not chosen by taste. A 1280×720 frame at 30 fps is 27.6 megapixels per second. Divide the ceiling by that figure and you get the encoder’s budget per pixel: 2.5 Mbps yields about 0.09 bits per pixel, 1.2 Mbps yields 0.043, and 600 kbps yields 0.022. Block-based encoders — VP8, H.264, and VP9 alike — hold a convincing frame on camera content down to roughly 0.03–0.04 bits per pixel; below that the quantiser is pushed hard enough that flat regions posterise and edges ring. Downscaling to 640×360 cuts the pixel rate to 6.9 megapixels per second, so the same 600 kbps now buys 0.087 bits per pixel — a quarter of the detail, but none of the mush. Tier 3 repeats the trick: 320×180 is 1.7 megapixels per second, so 250 kbps still delivers 0.14 bits per pixel.
The second thing worth internalising is that maxBitrate is a ceiling and never a target. The rate controller aims at the minimum of your ceiling and whatever the congestion controller has allocated, which makes the two directions asymmetric. Lowering the ceiling below the current allocation binds immediately, on the next encoded frame. Raising it above the allocation does nothing until the estimate itself recovers — so an up-step is permission to use more bandwidth rather than a command, and one that outruns the estimator produces no visible improvement while still risking a re-congestion event.
Keyframe budget sets the floor. At 250 kbps and 30 fps the average frame budget is roughly 1 kB, while a 320×180 keyframe lands between 8 and 15 kB — ten or more frame-times that the pacer has to spread out. Push the ceiling toward 50 kbps and keyframes stop fitting inside a second of pacing at all, so the keyframe itself becomes the congestion event and the stream oscillates between stall and flash. A 100–150 kbps floor is not conservatism; it is where codec overhead starts to dominate the channel.
Step 3 — React to the estimated bandwidth
The control input is availableOutgoingBitrate, read from the transport report in getStats() — not from any inbound-rtp report, where the field does not exist. Poll at 1 s intervals; faster polling adds main-thread cost without sharpening the estimate. Each tick, compare the estimate against your current tier’s ceiling and decide whether to step down, hold, or ramp.
async function readEstimate(pc) {
const stats = await pc.getStats();
for (const report of stats.values()) {
// availableOutgoingBitrate lives ONLY on the transport report
if (report.type === 'transport' && report.availableOutgoingBitrate != null) {
return report.availableOutgoingBitrate; // bits per second
}
}
return null; // estimate not yet available; hold current tier
}
let tierIndex = 0;
async function adaptOnce(pc, sender) {
const estimate = await readEstimate(pc);
if (estimate == null) return;
const current = TIERS[tierIndex];
// If the estimate falls below 80% of the current ceiling, step down immediately
if (estimate < current.maxBitrate * 0.8 && tierIndex < TIERS.length - 1) {
tierIndex += 1;
await applyTier(sender, TIERS[tierIndex]);
}
}
setInterval(() => adaptOnce(pc, sender), 1000); // 1 s control loop
Stepping down is the urgent path and should fire fast — a sustained estimate below 80% of your ceiling means packets are already queueing. The full down-step logic, including reading the estimate cleanly and handling transient nulls, is detailed in the companion deep-dive on reacting to bandwidth drops with RTCRtpSender parameters, which adds the hysteresis that keeps this loop from flapping. The opposite half of the cycle deserves its own dwell timer and probing margin, worked through in Ramping Bitrate Back Up After Congestion, because a recovery that is as eager as the down-step will simply re-congest the link.
Why the down-step must lead the estimator, not follow it
availableOutgoingBitrate is a lagging indicator by construction. The delay-based half of Google Congestion Control watches the gradient of one-way delay across groups of packets and needs several hundred milliseconds of consistently rising inter-arrival delay before it declares overuse and multiplies its estimate down. In practice the estimate reacts within 200–500 ms of the link actually degrading — fast for a controller, but an eternity for a queue. By the time your 1 s poll observes the collapse, the excess has already been buffered somewhere between the pacer and the first congested hop.
The arithmetic is unforgiving. An encoder emitting 2.5 Mbps onto a link that has just dropped to 900 kbps accumulates 1.6 Mbit of backlog per second. After a single poll interval that is roughly 200 kB sitting in queues, which takes about 1.8 s to drain at the new capacity — and every packet behind it inherits that delay. Each additional poll interval you spend descending one tier at a time adds another second of queued media to the viewer’s experience. This is why the controller should skip tiers: on a down-step, jump straight to the deepest tier whose ceiling fits under the new estimate rather than walking the ladder.
// Pick the deepest tier the current estimate can carry, instead of one step per tick
function tierForEstimate(estimate) {
const target = estimate * 0.9; // 10% headroom for keyframe spikes and retransmissions
for (let i = 0; i < TIERS.length; i++) {
if (TIERS[i].maxBitrate <= target) return i; // first tier that fits under the estimate
}
return TIERS.length - 1; // nothing fits: pin to the floor tier
}
You do not have to infer the queue indirectly. outbound-rtp carries totalPacketSendDelay alongside packetsSent, and the delta of the first divided by the delta of the second gives the average time each packet spent waiting in the pacer between the encoder handing it over and the transport sending it. On a healthy uplink that figure sits in the low single-digit milliseconds; once it climbs past roughly 100 ms per packet you are queueing, regardless of what the estimate currently reads. Tracking it beside the estimate turns a lagging signal into a leading one, and the wider set of counters worth sampling on the same tick is catalogued in Interpreting getStats() for Congestion Signals.
Step 4 — Choose a degradationPreference
When the encoder cannot meet its target — whether from network limits or CPU pressure — it must sacrifice either resolution or frame rate. degradationPreference on the encoding tells it which. The four values map directly to content type.
| degradationPreference | Sacrifices | Best for |
|---|---|---|
maintain-framerate |
Resolution | Screen share, motion, sports |
maintain-resolution |
Frame rate | Slides, text, detail-critical UI |
balanced |
Both, gradually | General camera video |
disabled |
Neither (drops frames) | Rarely; testing only |
const params = sender.getParameters();
// Screen content stays readable if framerate drops but text stays crisp
params.degradationPreference = 'maintain-resolution';
await sender.setParameters(params); // applies to the whole sender, not per-encoding
Note that degradationPreference sits at the top level of the parameters object in the current spec, not inside each encoding, though older Chrome builds read a per-encoding copy. For camera video, maintain-framerate keeps motion fluid by shedding resolution — usually the right default for conversational calls. For a shared spreadsheet, maintain-resolution keeps cell borders legible while frame rate sags, and pairing it with the track-level signal described in Keeping Shared Text Readable with contentHint tells the encoder the same thing from the capture side. The frame-by-frame consequences of each choice, including what viewers actually notice at 5–10 fps, are measured in degradationPreference: Resolution vs Framerate. Verify the change took effect by re-reading getParameters() and confirming frameWidth/framesPerSecond in the outbound-rtp stats move in the expected direction.
Step 5 — Verify the loop end to end
Adaptation that “looks wired up” frequently fails silently — a write is rejected, the estimate reads undefined, or the encoder never honors scaleResolutionDownBy. Treat verification as a first-class step rather than an afterthought, and confirm each link in the chain independently.
// Verification probe: confirm the encoder followed the last setParameters() write
async function verifyAdaptation(pc, sender) {
const params = sender.getParameters();
const intendedCeiling = params.encodings[0].maxBitrate; // what we asked for
const intendedScale = params.encodings[0].scaleResolutionDownBy;
const stats = await pc.getStats();
for (const report of stats.values()) {
if (report.type === 'outbound-rtp' && report.kind === 'video') {
console.log('intended ceiling', intendedCeiling,
'targetBitrate', report.targetBitrate, // encoder's own target
'frameHeight', report.frameHeight, // proves scale applied
'fps', report.framesPerSecond);
}
if (report.type === 'transport') {
console.log('estimate', report.availableOutgoingBitrate); // the control input
}
}
return intendedScale; // caller can assert frameHeight matches the expected division
}
Three checks confirm health. First, availableOutgoingBitrate on the transport report is a number, not undefined — if it is missing, transport-wide congestion control was never negotiated and the estimator falls back to coarser loss-only signals, a difference unpacked in Transport-CC vs REMB Feedback. Second, targetBitrate on outbound-rtp tracks the maxBitrate you set, proving the ceiling reached the encoder. Third, frameHeight halves when scaleResolutionDownBy goes from 1 to 2, proving downscaling is live rather than silently ignored. Run this probe in chrome://webrtc-internals alongside the live graphs to correlate your loop’s decisions with the browser’s own view. When all three move together under a throttled link, the loop is genuinely closed.
Separating network limits from CPU limits
A ladder that assumes every quality drop is a bandwidth problem will chase a CPU problem forever. outbound-rtp answers directly: qualityLimitationReason reports none, bandwidth, cpu, or other at the instant you sample, and qualityLimitationDurations accumulates seconds spent in each state over the sender’s lifetime — the more useful signal, because it survives between polls.
// Distinguish a congested link from an overloaded encoder before adapting
async function limitationProfile(pc) {
const stats = await pc.getStats();
for (const report of stats.values()) {
if (report.type === 'outbound-rtp' && report.kind === 'video') {
const d = report.qualityLimitationDurations || {};
return {
now: report.qualityLimitationReason, // instantaneous cause
bandwidthSec: d.bandwidth ?? 0, // cumulative seconds link-limited
cpuSec: d.cpu ?? 0, // cumulative seconds encoder-limited
resChanges: report.qualityLimitationResolutionChanges // browser's own downscales
};
}
}
return null;
}
When the reason is cpu, lowering maxBitrate is worse than useless — it removes headroom the encoder was not using while leaving the pixel rate that is actually saturating the core untouched. The correct response is to cut resolution or frame rate, or to stop software-encoding entirely; a 720p30 VP9 or AV1 software encode can occupy a full core on a mid-range laptop, while the same stream on a hardware H.264 path costs a fraction of that. Confirming which path you are on is covered in Detecting Hardware vs Software Encoding.
qualityLimitationResolutionChanges deserves its own alarm. It counts how many times the browser’s internal quality scaler changed resolution — a mechanism that runs independently of your ladder. If that counter climbs while your estimate and your tier are both flat, you have a double-adapter: the browser downscales by 2 in response to CPU, your ladder downscales by 2 in response to bandwidth, and the composed result is a 1/4-linear, 1/16-area frame that nobody asked for. The fix is to own exactly one of the two knobs — either hold scaleResolutionDownBy at 1 and let the browser’s scaler act under maintain-framerate, or drive resolution yourself and choose maintain-resolution so the internal scaler stands down.
Edge Cases & Browser Quirks
Safari rejects mid-playout writes. Safari (WebKit) limits maxBitrate adjustments while a track is actively rendering and may throw if you call setParameters() before connectionState reaches connected. Gate every write on the connected state, and on Safari prefer fewer, larger tier jumps over frequent small ones.
Firefox honors fewer fields per write. Firefox applies maxBitrate and scaleResolutionDownBy reliably but historically ignored scaleResolutionDownBy on the active simulcast encoding in some 110-era builds; test resolution actually changes via outbound-rtp frameHeight rather than trusting the call returned.
Chrome empties encodings before negotiation. On a freshly created sender, getParameters().encodings can be [] until the first offer/answer completes. Writing then throws; guard with the empty-array check from Step 1 and defer adaptation until the first outbound-rtp report appears.
transactionId staleness. If you cache a params object and write it later, the transactionId may already be stale because the browser issued a new one. Always call getParameters() immediately before each setParameters() — never reuse a snapshot across the 1 s loop boundary.
scaleResolutionDownBy below 1. Values under 1.0 (upscaling) are invalid and throw RangeError on Chrome. Clamp your ladder so the smallest divisor is exactly 1.
Non-integer divisors do not land where you expect. Chrome rounds computed dimensions to even values and libvpx/OpenH264 prefer multiples of 16, so a divisor of 1.5 on a 1280×720 capture can surface as 848×480 rather than the 853×480 the arithmetic implies. A check asserting frameHeight === captureHeight / divisor then fails on a working system. Allow a few pixels of tolerance, or restrict the ladder to divisors of 1, 2, and 4, where alignment is exact.
Changing the track changes what the divisor means. scaleResolutionDownBy is relative to the current capture, not absolute. Swap a 1280×720 camera for a 1920×1080 screen capture and a divisor of 2 that used to produce 640×360 now produces 960×540 — more than double the pixel rate under the same ceiling, which drops you straight below the bits-per-pixel floor. Recompute the ladder whenever source dimensions change; the swap itself is covered in Replacing Video Tracks Without Renegotiation.
Thermal throttling on iOS is invisible in the estimate. A sustained 1080p encode on an iPhone meets the OS clock governor after a few minutes. The link is unchanged and availableOutgoingBitrate stays flat; the only symptom is qualityLimitationReason flipping to cpu while frame rate sags. Lowering the ceiling will not help, lowering the capture resolution will.
Common Implementation Mistakes
- Reading
availableOutgoingBitratefrom the wrong report. It exists only ontransport. Pulling it frominbound-rtpyieldsundefined, and your loop silently never adapts. - Constructing a fresh
encodingsarray. Replacing the array instead of mutating the one fromgetParameters()discards thetransactionIdand throwsInvalidModificationError. - Overlapping writes. Firing
setParameters()again before the prior promise resolves drops updates or throwsInvalidStateError. Serialize with an in-flight flag. - Lowering bitrate without resolution. Starving
maxBitratewhile holding full resolution produces macroblocking; pair the two via a tier ladder. - No hysteresis. Stepping up and down on raw estimate noise causes visible quality pumping. Require a margin and a dwell time before any up-step.
- Ignoring
connectionState. Writing duringdisconnected/failedwastes work and, on Safari, throws. Pause adaptation outside the connected state. - Forgetting the pacer floor. Setting
maxBitratenear zero starves the encoder below what it needs for a single keyframe; keep a sane floor around 100–150 kbps. - Treating
maxBitrateas a target. Raising the ceiling on a congested link produces no measurable change because the encoder aims at the minimum of ceiling and allocation. InstrumenttargetBitrateso an up-step that did nothing is visible rather than assumed to have worked. - Descending one tier per tick. Walking the ladder costs roughly a second of additional queued media for every rung you skip past the correct one. Compute the destination tier from the estimate and jump.
- Adapting without reading
qualityLimitationReason. A CPU-limited encoder responds to resolution and frame rate, not to bitrate. Cutting the ceiling in that state removes headroom without relieving the bottleneck.
FAQ
Where do I read the bandwidth estimate from?
The availableOutgoingBitrate field on the transport report returned by RTCPeerConnection.getStats(), in bits per second. It is not present on inbound-rtp or outbound-rtp. Poll at 1 s intervals and treat a null/undefined value as “estimate not ready — hold the current tier.”
Should I change maxBitrate or scaleResolutionDownBy first?
Step them together as a coordinated tier. Lowering maxBitrate alone forces the encoder to render full-resolution frames on a starved budget, which looks worse than a clean resolution drop. Above ~500 kbps for 720p, bitrate-only trimming is fine; below it, downscale.
Does setParameters() trigger renegotiation?
No. setParameters() adjusts encoder behavior on the existing transceiver without touching SDP, so there is no offer/answer round trip and no glare risk. This is precisely why it is the right surface for adaptation — changes apply in well under a frame interval rather than the multi-second cost of renegotiation.
Why does my video quality oscillate even on a stable link?
The raw estimate is noisy, and a loop that reacts to every wiggle will pump quality. Add hysteresis: require the estimate to exceed the next tier’s ceiling by ~15% and stay there for several seconds before ramping up, while stepping down quickly. The mechanics are covered in the reacting to bandwidth drops deep-dive.
Is maxBitrate per encoding or for the whole sender?
Per encoding. With a single encoding the distinction is invisible, but under simulcast the browser budgets the sum: three encodings at 2.5 Mbps, 1.2 Mbps, and 600 kbps ask the pacer for 4.3 Mbps in aggregate, not 2.5. Sizing each layer as if it were the only one is the most common reason a simulcast publisher saturates an uplink it thought had headroom. Budget the sum against the estimate, and shed whole layers with active: false before trimming ceilings.
What does my ceiling change look like to a media server?
For a single-encoding publisher, a selective forwarding unit forwards what it receives, so a drop to 250 kbps degrades the stream for every subscriber in the room at once — the server adds only 1–3 ms of forwarding overhead and has no way to reconstruct quality it never got. That is the structural argument for simulcast the moment you have more than a handful of subscribers: it moves the per-subscriber choice to the server, where it belongs, as described in Bandwidth-Aware Layer Selection in an SFU.
How long does a tier change take to reach the viewer?
A maxBitrate-only change is honoured by the rate controller on the next encoded frame — around 33 ms at 30 fps — and reaches the viewer after one network transit, plus 20–40 ms one-way if the path is relayed through TURN. A change that also moves scaleResolutionDownBy costs a keyframe on top, so the viewer sees the new resolution one keyframe interval later and pays a 3–8× frame-size spike for it. That spike is why down-steps should be decisive rather than frequent: two resolution changes a second cost more bandwidth than they save.
Related: this guide sits under Media Handling, Codecs & Bandwidth Estimation; pair it with Bandwidth Estimation & Congestion Control for the estimator internals, Simulcast & SVC Implementation for multi-layer fan-out, and the focused walkthrough on reacting to bandwidth drops with RTCRtpSender parameters.