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.

Adaptive bitrate control loop Bandwidth estimate from getStats feeds an adaptation controller, which calls setParameters to adjust maxBitrate and scaleResolutionDownBy on the encoder, whose RTP output is measured back into the estimate. getStats() availableOutgoingBitrate Adaptation controller + hysteresis setParameters() maxBitrate + scale Encoder + Pacer RTP to network measure to estimate
The adaptation loop: estimate to controller to sender parameters to encoder, measured back into the estimate.

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.

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.

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 and recovery ramp that keep this loop from flapping.

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. 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, TWCC was never negotiated and the estimator falls back to loss-only signals. 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.

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.

Common Implementation Mistakes

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.

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.