When a screen share gets choppy, most people assume the network is bad. Sometimes it is. But there is a quieter failure mode where the network is fine, the encoder is fine, and the picture still collapses to single-digit frame rates while resolution never moves. The user ends up with the worst of both worlds: a soft frame that also stutters.
The reason is a default you probably never set: which dimension the pipeline is allowed to sacrifice under pressure.
The default is not "lower resolution"
WebRTC exposes degradationPreference on a sender, with three values: maintain-resolution, maintain-framerate, and balanced. The names suggest a symmetric trade-off. The behavior is not symmetric in practice.
For screen content, many stacks lean toward preserving resolution, because text and tables are unreadable if you halve the dimensions. That reasoning is sound for a still image. Video is not a still image. When the encoder is told to keep 3024×1964 and the bitrate budget tightens, it cannot keep quality by magic. It keeps the pixel count, spends fewer bits per frame, and the result is visibly soft. At the same time, the pacing logic has to shed something, and what it sheds is frame rate.
So "maintain resolution" does not actually protect legibility. It protects a number. What the viewer sees is a soft, low-frame-rate stream that is neither sharp nor fluid.
What our measurement showed
We ran an end-to-end capture of a 3024×1964 window, H.264 through the hardware encoder (VideoToolbox), sender and viewer on the same subnet.
- Median frame rate: 29 fps. Minimum: 3 fps.
- Single-frame encode time: 12.3 ms.
- Dropped frames reported by the encoder: 0.
-
limitqueried from the capture side:nonein all 501 of 501 samples.
Read those together. The encoder is not struggling — 12.3 ms per frame leaves headroom. No frames were dropped at the encoder. The bandwidth estimator never once asked the pipeline to constrain itself. And yet the delivered frame rate bottomed out at 3.
That is the important part: adaptation never engaged. The 3 fps was not the controller choosing a lower frame rate under a bandwidth limit. It was the absence of any decision. Resolution stayed at full size the entire run because nothing touched it, and frame rate fell for reasons upstream of the decision the controller makes.
If you only watch average frame rate, this looks like "bad network." It is not. It is a pipeline with no owner for the resolution-versus-frame-rate choice.
Why the frame rate is the first thing to go
Three mechanisms conspire:
- Capture is uneven. Screen content alternates between still and scrolling. A capture loop that emits at a fixed rate builds queue depth during scrolls. The frame rate you measure is an average over wildly varying demand.
- Encode time is roughly fixed. 12.3 ms per frame is a throughput ceiling that does not move when content gets harder; the demand does. Throughput flat, demand spiky: queueing is the only possible result.
- The jitter buffer is built for jitter, not for stops. If the sender pauses for 200 ms, the buffer underruns and the viewer freezes, while every network counter stays perfect.
None of these are solved by a preference flag. A preference says what to do if the engine decides to act. It does not make the engine act, and it does not remove capture-side load.
Three things you can actually control
1. Set degradationPreference explicitly
Do not inherit it. Decide, per session, what the product needs:
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const params = sender.getParameters();
params.degradationPreference = 'maintain-framerate';
await sender.setParameters(params);
For screen sharing where motion and responsiveness matter, maintain-framerate is often the better default — a soft frame that keeps updating is easier to follow than a sharp frame that freezes. For a document being read line by line, the opposite may be true. Either way, the choice is a product decision, not something to leave to the engine.
2. Maintain your own resolution ladder
Instead of waiting for adaptation, step resolution yourself on a schedule tied to observed conditions:
3024×1964 → 1920×1247 → 1280×831 → 960×623
Call applyConstraints with a reduced width/height, or use scaleResolutionDownBy on the sender. The point is that each step is a real, bounded reduction that lowers encode cost and queue depth — not a preference the engine may ignore.
3. Limit resolution at capture
The cheapest fix is upstream: do not capture at native size and hope the encoder manages it. Cap the capture dimensions to what the viewer can actually resolve. A 3024-pixel-wide frame on a 1440-pixel viewer is mostly wasted bits, and it makes every downstream stage more expensive. The pipeline gets easier at every stage when the source is already the size you intend to send.
The decision you are not making
The uncomfortable summary: when the engine keeps resolution and drops frames, it is making a choice you did not make. That choice is defensible for a photograph and wrong for a live screen. A number that preserves legibility at 3 fps is an illusion — a sharp frame no one can read because it never changes.
Instrument the frame-rate distribution, not the average. Watch whether limit ever leaves none. If it never does, adaptation is not saving you, and the resolution ladder is yours to build.
I also made a free calculator for this: Screen Sharing Latency & Bandwidth Calculator — plug in resolution, frame rate, codec, and available bandwidth to see where the budget breaks.
Top comments (0)