WebRTC · Chromium · LiveKit · screen sharing
Why Chromium Screen Sharing Falls Short of 60 FPS
Selecting 1080p60 does not guarantee that a browser will produce 60 frames every second. This article traces frames from getDisplayMedia to the WebRTC encoder and explains a constant-frame-rate fix built with canvas, requestFrame(), and a one-pixel update.
August 01, 2026 · 6 min read
A 60 FPS setting does not guarantee 60 output frames
Browser screen capture has several independent frame-rate controls. An application can select a 1080p60 preset, request 60 FPS from getDisplayMedia(), and configure the WebRTC sender with maxFramerate: 60. None of those settings guarantees that the capture source will actually produce 60 frames per second.
The issue in this implementation appeared with static or nearly static surfaces in Chromium. The native capture track and encoder cadence could remain near the previous 30 FPS ceiling even though the target was 60 FPS. The evidence supports a variable, content-adaptive cadence. Describing it as resource saving is a reasonable interpretation of the optimization, but it was not separately measured as the cause of a specific production session.
Chromium can avoid forwarding unchanged frames and can limit capture work to reduce unnecessary CPU use. That behavior makes sense for a document or slide deck. It becomes a problem when the downstream pipeline expects a steady 60 FPS cadence.
FPS can disappear at more than one stage
A screen share is a pipeline, not a single FPS counter:
Each stage can report a different number. A useful diagnosis separates at least these signals:
| Stage | Signal | What a low value suggests |
|---|---|---|
| Track configuration | track.getSettings().frameRate | A negotiated setting, not proof of actual cadence |
| Capture source | media-source.framesPerSecond | The browser is not feeding enough frames into the pipeline |
| Encoder output | outbound-rtp.framesPerSecond | Frames are being limited or lost before transmission |
| Playback | getVideoPlaybackQuality() | Delivery, decoding, or rendering may be the bottleneck |
The implementation reads real capture FPS from the RTC media-source report and output FPS from outbound-rtp. This distinction matters because getSettings().frameRate may still show the requested or negotiated rate while actual frame delivery is lower.
Why constraints and content hints were not enough
The initial setup looked correct:
videoTrack.contentHint = 'motion';
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 60, max: 60 },
},
});
contentHint = 'motion' tells WebRTC to favor motion and frame rate over maximum detail. ideal: 60 states a preference, while max: 60 sets an upper bound. Neither setting forces a static source to generate a fresh frame every 16.7 milliseconds.
The high-FPS preset also applies a stricter constraint after the user has selected a source:
await track.applyConstraints({
frameRate: { min: 60, ideal: 60, max: 60 },
});
This must happen after the picker because the W3C Screen Capture specification rejects min and exact constraints in the initial getDisplayMedia() call. If the selected source cannot satisfy the requirement, the code retries with the non-mandatory max: 60.
The publishing layer also needs degradationPreference: 'maintain-framerate', maxFramerate: 60, and no unnecessary simulcast encodings. These settings cannot manufacture frames, but they prevent a later stage from reintroducing a 30 FPS cap or spending CPU on multiple encoding layers.
The CFR fix separates image updates from output cadence
When the native source uses a variable frame rate, the application can create a separate constant-frame-rate output track. Native getDisplayMedia() remains responsible for the image, while a canvas wrapper controls when frames reach LiveKit.
The native track is attached to a hidden <video> through srcObject. requestVideoFrameCallback() runs when the source actually supplies a new frame. Only then is the full image copied to the canvas, avoiding an unnecessary 1920×1080 redraw on every output tick.
The canvas track is created in manual mode:
const outputStream = canvas.captureStream(0);
const outputTrack = outputStream.getVideoTracks()[0];
A zero frame request rate leaves scheduling to the application. For a 60 FPS target, the code requests an output frame every 1000 / 60, or roughly every 16.7 milliseconds:
let tick = false;
const frameTimer = window.setInterval(() => {
if (sourceTrack.readyState === 'ended') return;
tick = !tick;
context.fillStyle = tick ? 'rgb(0, 0, 0)' : 'rgb(1, 1, 1)';
context.fillRect(0, 0, 1, 1);
outputTrack.requestFrame?.();
}, 1000 / targetFps);
The code does not emit one frame per second. It requests one frame every 1/60 of a second.
Why the fix changes one pixel
The CanvasCaptureMediaStreamTrack rules tie a new frame to both a frame request and newly painted canvas content. Calling requestFrame() on a completely unchanged canvas may not produce an observably new frame.
Redrawing the entire 1080p image 60 times per second would add substantial CPU and rasterization work. Instead, each timer tick flips one 1×1 pixel between rgb(0, 0, 0) and rgb(1, 1, 1). The visual change is effectively invisible, but the canvas content is no longer identical.
This stabilizes output cadence; it does not recreate motion that the source never captured. If the native source supplies 30 meaningfully different frames, the output track can deliver 60 frames, but some will repeat the latest source image with the one-pixel change. This is a CFR adapter, not frame interpolation.
Where to use the workaround
The canvas frame pump is enabled only for presets above 30 FPS. A 1080p30 stream keeps the native track because an extra canvas, timer, and copy path would consume CPU without improving the target cadence.
The implementation also needs graceful fallback behavior. If canvas.captureStream, the 2D context, or hidden-video playback is unavailable, it publishes the native track. Cleanup must cancel the timer and requestVideoFrameCallback, then stop both the generated output and the original display-capture track. Otherwise capture may continue after the stream ends.
setInterval() is not a real-time scheduler. Heavy CPU load and background-tab throttling can still reduce cadence. The workaround therefore complements RTC monitoring; it does not replace it.
How the regression is tested
The E2E regression scenario feeds a synthetic 30 FPS source into the 1080p60 preset. Before the fix, the scenario was expected to detect a frame-rate failure. With the CFR wrapper, its contract requires the output to remain above the old 30 FPS ceiling.
The repository defines these gates:
- current output FPS of at least 48;
- average output FPS of at least 54;
- a profile target of 60 FPS.
These are regression thresholds, not a stored production benchmark. They show that the controlled pipeline no longer sticks at 30 FPS, but they do not promise a full 60 FPS on every device or at the viewer.
A practical test should record source FPS, outbound FPS, CPU limitation reason, dropped frames, and viewer playback FPS. Looking at all five separates content-adaptive capture from encoder overload and delivery problems.
Practical takeaway
When a browser screen share falls short of 60 FPS, first locate the stage where frames disappear. ideal: 60 and maxFramerate: 60 express intent; they do not guarantee constant cadence from a static source.
If a product truly requires constant-frame-rate output, a focused compromise is to keep native capture as the image source, repaint the full canvas only on real source frames, and request intermediate output frames through canvas.captureStream(0) and requestFrame(). A one-pixel update makes those frames observably new without forcing a full 1080p repaint 60 times per second.