degradationPreference: Resolution vs Framerate

When the encoder cannot afford the stream you asked it for, something is thrown overboard: pixels or frames. degradationPreference is the one-word policy that decides which. This deep-dive is part of the Adaptive Bitrate Streaming in WebRTC guide, and it answers a single question: given a talking head, a shared spreadsheet, or a sports feed, do you set maintain-framerate, maintain-resolution, or balanced β€” and what exactly does the encoder shed once you have?

Context & Trade-offs

The name is misleading in a useful way. degradationPreference does not degrade anything itself and it does not set a bitrate. It configures the adaptation module that sits between your capture source and the encoder. When the rate controller says the current frame size and rate cannot be encoded inside the target bitrate, or when the CPU overuse detector says encoding is taking too long, that module pushes a new constraint back at the source β€” a maximum pixel count, a maximum frame rate, or both. The source then scales or drops frames before they ever reach the encoder, which is why the change shows up as a real frameHeight or framesPerSecond movement in outbound-rtp rather than as a quieter rise in quantiser.

The steps are discrete and surprisingly coarse. A resolution down-step multiplies the pixel count by roughly three fifths, so a 1280Γ—720 frame (921,600 pixels) lands near 960Γ—540, then near 720Γ—406, then near 540Γ—304. A frame-rate down-step multiplies the current rate by two thirds: 30 β†’ 20 β†’ 13 β†’ 9. Both ladders have hard floors β€” around 320Γ—180 pixels and 2 fps β€” and once a mode hits its floor there is nothing left to shed, so the encoder falls back to raising the quantiser and the picture simply gets blocky. That floor is the single most important thing to know when picking a mode, because it defines the bitrate below which your chosen policy stops being a policy at all.

Mode Adapter may change Held fixed 720p30 driven down to 600 kbps Fits
maintain-framerate pixel count frame rate ~720Γ—406 at 30 fps faces, motion, gameplay
maintain-resolution frame rate pixel count 1280Γ—720 at ~13 fps text, charts, code, UI
balanced both, alternating neither ~640Γ—360 at ~20 fps mixed or unknown content
disabled nothing both 720p30 with saturated QP tests and recordings only

A talking head is the easy case. Head-and-shoulders video against a static background produces small inter-frame residuals, so 720p30 sits comfortably at 1.2–1.5 Mbps and the codec’s own quantiser absorbs the first few hundred kbps of loss without any adaptation at all. Below roughly 400 kbps you have to shed something, and human perception is lopsided: a face at 360p30 reads as β€œslightly soft”, while the same face at 720p9 reads as broken β€” gestures stutter, and although audio and video stay in sync, viewers report them as drifting. Shed pixels, keep frames: maintain-framerate.

A shared spreadsheet inverts every one of those assumptions. Legibility is a pixel-density problem, not a bitrate problem: an 11 px cell label at half scale is unreadable no matter how many bits you spend on it. The content is also static for seconds at a time, so frames are nearly free to skip β€” 5 fps of a crisp sheet costs less and communicates more than 30 fps of a downscaled one. Set maintain-resolution, and set the track’s contentHint too; Chrome derives a default preference from the hint when you leave the field unset, mapping 'text' and 'detail' onto resolution-preserving behaviour, which is covered in depth in Keeping Shared Text Readable with contentHint.

A sports feed is the genuinely hard case, because it wants both: large motion vectors make every frame expensive, and viewers still need to resolve a ball against a crowd. maintain-framerate is the right starting point, but it degrades badly at the bottom. Held at 30 fps down a collapsing link, the ladder reaches the 320Γ—180 floor and everything after that is quantiser mush. Empirically the crossover sits near 400 kbps: above it, 30 fps at a reduced size wins; below it, 15 fps at 640Γ—360 preserves far more of what a viewer is trying to see. That is precisely what balanced does β€” it walks the resolution ladder until the frame size drops under an internal threshold, then starts trading frame rate instead, alternating between the two rather than exhausting one.

Content types mapped to degradationPreference regions A plane with source motion on the horizontal axis and detail sensitivity on the vertical axis, divided into three diagonal regions for maintain-resolution, balanced and maintain-framerate, with a shared spreadsheet, a talking head and a sports feed plotted in each. maintain-resolution balanced maintain-framerate shared spreadsheet talking head sports feed low high source motion, low to high detail sensitivity, low to high
Where each source sits: detail-heavy and static goes left, motion-heavy goes right, and the diagonal band is where balanced earns its keep.

One structural detail catches people out. In the current specification degradationPreference lives at the top level of RTCRtpSendParameters, alongside encodings, not inside each encoding β€” so it is a per-sender policy, and with simulcast every layer inherits it. Older Chrome builds read a per-encoding copy instead, which is why production code writes both slots in the same call.

Minimal Runnable Implementation

The whole configuration is one read-mutate-write against the sender, classifying the track first so the policy follows the content rather than a hardcoded guess.

// One preference per content class. These are policies, not bitrates.
const PREF_BY_CONTENT = {
  document: 'maintain-resolution', // text must stay legible; frames are cheap to skip
  camera:   'maintain-framerate',  // faces tolerate softness far better than judder
  motion:   'maintain-framerate',  // shared video playback, gameplay, sports
  mixed:    'balanced'             // unknown or switching content
};

// Classify from contentHint first β€” it is also what Chrome uses to pick its own
// default when degradationPreference is left unset.
function classifyTrack(track) {
  switch (track.contentHint) {
    case 'text':
    case 'detail': return 'document';
    case 'motion': return 'motion';
    default:
      // displaySurface is only present on getDisplayMedia tracks
      return track.getSettings().displaySurface ? 'document' : 'camera';
  }
}

async function configureSender(sender, ceilingBps) {
  if (sender?.track?.kind !== 'video') return null;
  const params = sender.getParameters();          // fresh transactionId for every write
  if (!params.encodings?.length) return null;     // sender not negotiated yet β€” defer
  const pref = PREF_BY_CONTENT[classifyTrack(sender.track)];
  params.degradationPreference = pref;            // spec position: top level
  for (const enc of params.encodings) {
    enc.degradationPreference = pref;             // legacy per-encoding slot (older Chrome)
    enc.maxBitrate = ceilingBps;                  // preference says WHAT is shed...
    enc.scaleResolutionDownBy = 1;                // ...maxBitrate says WHEN shedding starts
  }
  await sender.setParameters(params);
  // Read back: some engines accept the write but never echo the field.
  return sender.getParameters().degradationPreference ?? pref;
}

const videoSender = pc.getSenders().find(s => s.track?.kind === 'video');
// 1.2 Mbps is a realistic 720p30 conversational ceiling; raise it for motion content.
const applied = await configureSender(videoSender, 1_200_000);
console.log('degradationPreference applied:', applied);

Two things make this behave in production. First, the preference and the ceiling are written in the same setParameters() call, so there is no window where the encoder has a new ceiling but the old shedding policy. Second, scaleResolutionDownBy stays at 1 here deliberately: it is an application-imposed ceiling applied at capture, and adaptation then operates below whatever it produces. If you set it to 2 and also pick maintain-resolution, the resolution that gets maintained is the already-halved one β€” which is usually not what anyone intended.

What each mode emits as the ceiling falls A grid with three rows for maintain-framerate, maintain-resolution and balanced, and four columns for ceilings of 2.5 Mbps, 1.2 Mbps, 600 kbps and 250 kbps, showing the resolution and frame rate the encoder produces in each combination. Encoder output from a 1280x720 at 30 fps source degradationPreference 2.5 Mbps 1.2 Mbps 600 kbps 250 kbps maintain-framerate sheds pixels only 1280x720 30 fps 960x540 30 fps 720x406 30 fps 320x180 30 fps, at floor maintain-resolution sheds frames only 1280x720 30 fps 1280x720 20 fps 1280x720 13 fps 1280x720 5 fps balanced alternates the two 1280x720 30 fps 960x540 30 fps 640x360 20 fps 480x270 10 fps Floors: about 320x180 pixels and 2 fps. Past a floor the encoder raises QP instead of adapting.
The same source under four ceilings: each mode walks a different ladder, and each ladder ends at a floor.

Reproduction Steps & Debugging Log Patterns

  1. Publish a 1280Γ—720 at 30 fps track, call configureSender() with a 2.5 Mbps ceiling, and confirm from outbound-rtp that frameWidth is 1280 and framesPerSecond is near 30 before you constrain anything. If it is not, the baseline is wrong and every later reading is noise.
  2. Squeeze the uplink to roughly 600 kbps β€” tc qdisc add dev eth0 root netem rate 600kbit on Linux, or a DevTools throttling profile. Keep the same 1 s getStats() cadence used elsewhere in the adaptation loop.
  3. Log the four fields that prove which ladder is moving: frameWidth, framesPerSecond, qualityLimitationReason, and qualityLimitationResolutionChanges. The last one is a monotonic counter and is the cleanest single signal that the resolution ladder β€” not the frame-rate ladder β€” is being walked.
  4. Repeat the identical throttle with each mode and diff the traces. maintain-framerate should move frameWidth and leave fps alone; maintain-resolution should do the exact opposite and leave qualityLimitationResolutionChanges pinned at its starting value.
  5. Release the throttle and watch recovery. Resolution climbs back roughly one step per few seconds, not instantly, because the adapter waits for sustained headroom β€” the same conservatism described in Ramping Bitrate Back Up After Congestion.
// maintain-framerate under a 600 kbps squeeze β€” pixels move, fps does not
// t+0s  1280x720 30fps reason=none    resChanges=0
// t+2s  1280x720 29fps reason=bandwidth resChanges=0   // rate controller notices
// t+3s   960x540 30fps reason=bandwidth resChanges=1   // first pixel step
// t+5s   720x406 30fps reason=bandwidth resChanges=2   // second pixel step
//
// maintain-resolution, identical network β€” fps moves, pixels do not
// t+3s  1280x720 20fps reason=bandwidth resChanges=0
// t+6s  1280x720 13fps reason=bandwidth resChanges=0

If qualityLimitationReason reports cpu rather than bandwidth, the network is not your problem and no preference will fix it β€” the encoder is missing its deadline, which usually means a software codec on a busy machine; confirm with Detecting Hardware vs Software Encoding. The mode still governs how the CPU pressure is relieved, but the fix is elsewhere. The full vocabulary of these stat fields is unpacked in Interpreting getStats() for Congestion Signals.

Picking a preference from live stats A decision tree starting at qualityLimitationReason, branching into cpu, bandwidth and none, with the bandwidth branch splitting by content type into maintain-resolution, maintain-framerate and a balanced fallback. poll outbound-rtp every 1 s qualityLimitationReason cpu encoder misses deadline bandwidth rate controller capped none nothing is adapting fix the codec path first, then keep the same mode maxBitrate is the limit, raise the ceiling static text or UI spreadsheet, slides, code faces and gestures talking head, webinar continuous motion sports, video playback maintain-resolution floor is 2 fps maintain-framerate floor is 320x180 maintain-framerate balanced under 400 kbps
Read the limitation reason first, then the content: only the bandwidth branch is a preference decision.

Common Implementation Mistakes

FAQ

Does degradationPreference change how much bandwidth the stream uses?

No. It has no effect on the target bitrate whatsoever β€” that is entirely maxBitrate plus the congestion controller’s estimate. The preference only decides which dimension is sacrificed once the encoder cannot fit the frame it was given into the bitrate it was allowed, so it changes the shape of the output, never the volume. A stream that is too expensive at every resolution and every frame rate needs a lower ceiling, not a different preference.

How does it interact with simulcast?

It applies to the sender, so all three encodings share one policy β€” you cannot ask the 1/2-scale layer to shed pixels while the full-scale layer sheds frames. In practice maintain-framerate is the safe choice for a simulcast camera sender, because the layer ladder already provides the resolution steps and a receiver that needs fewer pixels simply subscribes lower, as set up in Simulcast with Three Quality Layers in Chrome.

When is disabled ever the right answer?

Almost never in a live call. It tells the adapter to leave the source untouched, so under pressure the encoder holds frame size and rate and lets the quantiser saturate, producing blocky output and a growing pacer queue. It is genuinely useful in two places: measurement runs where you need a fixed input to compare codecs fairly, and local recording paths where the captured geometry must not change mid-file.

Related: this walkthrough sits under Adaptive Bitrate Streaming in WebRTC; for the control loop that sets the ceilings this policy reacts to see Reacting to Bandwidth Drops with RTCRtpSender Parameters, and for the recovery side read Ramping Bitrate Back Up After Congestion.