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.
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.
Reproduction Steps & Debugging Log Patterns
- Publish a 1280Γ720 at 30 fps track, call
configureSender()with a 2.5 Mbps ceiling, and confirm fromoutbound-rtpthatframeWidthis 1280 andframesPerSecondis near 30 before you constrain anything. If it is not, the baseline is wrong and every later reading is noise. - Squeeze the uplink to roughly 600 kbps β
tc qdisc add dev eth0 root netem rate 600kbiton Linux, or a DevTools throttling profile. Keep the same 1 sgetStats()cadence used elsewhere in the adaptation loop. - Log the four fields that prove which ladder is moving:
frameWidth,framesPerSecond,qualityLimitationReason, andqualityLimitationResolutionChanges. 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. - Repeat the identical throttle with each mode and diff the traces.
maintain-framerateshould moveframeWidthand leave fps alone;maintain-resolutionshould do the exact opposite and leavequalityLimitationResolutionChangespinned at its starting value. - 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.
Common Implementation Mistakes
- Writing only one of the two slots. The field is top-level in the current spec but older Chrome reads a per-encoding copy. Set both in the same
setParameters()call and the same code works across versions; set one and the browser silently keeps its default. - Trusting the read-back. Some engines accept the write and then return
nullor an unchanged value fromgetParameters(). Verify behaviourally β throttle the link and watch which offrameWidthandframesPerSecondactually moves β never by asserting on the echoed field. - Picking
maintain-frameratefor a screen share because scrolling looked choppy. You will trade illegible text for smoother scrolling, which is the wrong trade for a document. Fix the ceiling or thecontentHintinstead. - Combining
maintain-resolutionwith an aggressivescaleResolutionDownBy. The mode preserves whatever the source hands over, so a divisor of 2 means you are faithfully maintaining a half-size frame. Keep the divisor at 1 when resolution is the thing you care about. - Judging the result from the local preview. The preview element renders the capture stream, which never changes. Only
outbound-rtpshows what the encoder actually emitted.
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.