Someone shares a spreadsheet in a ten-person call. The first frame looks fine. Then they scroll, and the cells turn to mush for two or three seconds before the text sharpens again. Nobody on the call can read the number they are being asked about.

This is the most common complaint about WebRTC screen sharing, and it is rarely a codec bug. It comes from defaults that were tuned for webcams, plus a bandwidth budget that every camera feed in the room is competing for. This post walks through why it happens and the four changes that fix most of it: telling the encoder what kind of content it has, sending screen share as one high-quality layer, shrinking the presenter’s camera, and pausing video that viewers are not looking at.

Why WebRTC screen sharing goes blurry

A WebRTC video encoder has a target bitrate set by congestion control. When the network or CPU can’t sustain the current quality, the encoder has to give something up. It can lower the frame rate, lower the resolution, or quantize more aggressively (which smears fine detail).

For a webcam, dropping resolution is the right call. A face at 360p is still a face, and a choppy 8 fps call feels broken. For a screen full of 11px text, it is the wrong call. Downscaled or heavily quantized text is unreadable, while a slide that updates at 5 fps is perfectly usable.

The browser doesn’t know which kind of track it has unless you tell it. Scrolling makes it worse because every pixel changes at once, the encoder suddenly needs far more bits for the same quality, and it falls back to whatever trade-off it defaults to.

There is a second problem in group calls. Each participant’s downlink is shared by every stream they receive. If a viewer is pulling nine camera feeds and a screen share through the same connection, the screen share is one of ten streams fighting for the same bandwidth estimate.

Tell the encoder what it is encoding

The MediaStreamTrack Content Hints spec gives you a one-line fix for the first problem. Setting contentHint on a video track to "detail" or "text" tells the browser to avoid the quantization and downscaling artifacts that make small text unreadable. Per the spec, when the application hasn’t set a degradationPreference itself, "detail" and "text" map to "maintain-resolution": the sender gives up frame rate before it gives up resolution. "motion" maps to "maintain-framerate", which is what you want for a webcam or a shared video.

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: {
    width: { ideal: 1920, max: 3840 },
    height: { ideal: 1080, max: 2160 },
    frameRate: { ideal: 15, max: 30 },
  },
});

const track = stream.getVideoTracks()[0];
if ('contentHint' in track) {
  track.contentHint = 'detail';
}

A few things about getDisplayMedia() worth knowing, from MDN:

  • It requires transient user activation, so call it from a click handler, not on page load.
  • It only works in secure contexts.
  • The constraints don’t limit what the user can pick. They are applied after the user chooses a surface.
  • The permission can’t be persisted. Users are prompted every time.

This is not a niche fix. A mediasoup forum thread from January 2025 describes exactly the scrolling blur above. Raising the bitrate and switching codecs didn’t help. Setting contentHint = 'detail' did.

Notice the frame rate constraint: ideal: 15. For slides and code, 15 fps is plenty, and every frame you don’t send leaves bits for the frames you do.

Send screen share as one layer, not simulcast

Simulcast is the standard way to serve mixed networks from an SFU. The sender encodes the same camera at several resolutions, and the SFU forwards whichever layer each receiver can handle. For cameras this is a clear win. A viewer on a weak connection gets 180p instead of a frozen 720p.

For screen share, the low layers are close to useless. A 1080p slide scaled down by 4 is 480x270, and nobody can read a terminal at that size. Encoding those layers still costs the presenter upload bandwidth and CPU.

The team at Multi reached the same conclusion when they rebuilt their screen share pipeline: they avoided multi-layer encoding because shared text becomes unreadable at lower quality, and they would rather show fewer frames at high quality.

So encode cameras and screens differently:

// Camera: three simulcast layers
const cameraEncodings = [
  { maxBitrate: 100_000, scaleResolutionDownBy: 4 }, // ~180p
  { maxBitrate: 300_000, scaleResolutionDownBy: 2 }, // ~360p
  { maxBitrate: 1_500_000 },                         // 720p
];

// Screen: one layer with room to breathe
const screenEncodings = [{ maxBitrate: 3_500_000 }];

The trade-off is real. A viewer whose connection can’t sustain the screen share layer gets a lower frame rate rather than a smaller picture. With contentHint = 'detail' that is the behavior you asked for.

Residential upload bandwidth is often the tightest link in the whole call. A presenter sending a 720p camera with simulcast and a 1080p screen share at the same time is asking a lot of it.

The fix is to shrink the presenter’s own camera while they share, using applyConstraints on the camera track, and restore it when they stop:

async function setCameraSize(stream: MediaStream, width: number, height: number) {
  for (const t of stream.getVideoTracks()) {
    try {
      await t.applyConstraints({ width, height });
    } catch (e) {
      console.warn('Camera constraint change failed', e);
    }
  }
}

// On share start
await setCameraSize(cameraStream, 480, 360);

// On share stop
await setCameraSize(cameraStream, 1280, 720);

Nobody is looking at the presenter’s face in high resolution while they walk through a dashboard. The bits are better spent on the dashboard.

Pause the video nobody is watching

The last change is on the SFU side, and it has the biggest effect in larger calls.

Take a ten-person meeting where every camera is capped at 1.5 Mbps on its top layer. A viewer receiving all nine remote cameras at that layer is pulling up to 13.5 Mbps of camera video before the screen share arrives. Once a screen share starts, most of those camera tiles shrink into a sidebar or disappear entirely.

In an SFU like mediasoup, each receiver has its own server-side Consumer per remote track. Pausing a Consumer stops the SFU from forwarding that stream to that one receiver. The sender keeps uploading, and other receivers are unaffected. If a viewer keeps three camera feeds and pauses six, their camera downlink drops to at most 4.5 Mbps, and the congestion controller has room to give the screen share its full bitrate.

Pausing beats closing because resuming is fast. The producer is still live on the server, so there is no new getUserMedia call, no camera warm-up, and no renegotiation. The SFU starts forwarding again and the receiver decodes from the next keyframe.

The policy for which feeds to keep is up to you. A simple one: keep the host and the participants who are unmuted, up to three, and pause the rest. Re-evaluate when someone unmutes or leaves.

How screen sharing works in CoveKit

Everything above is how CoveKit’s meeting app handles screen sharing today, and the pieces are available in the TypeScript SDK if you build your own UI.

publishTrack with { source: 'screen' } publishes a single layer at up to 3.5 Mbps. The same call without a source publishes a camera track with the three simulcast layers shown above.

import { CoveKitClient, CoveKitRoom } from 'covekit';

const client = new CoveKitClient();
const session = await client.joinPublicMeeting('your-meeting-id', 'Alice');

const room = new CoveKitRoom(client, {
  onConnected: () => room.publishLocalMedia(true, true),
  onRemoteTrack: (track, peerId, kind, appData) => {
    if (appData?.source === 'screen') {
      // render in the main stage
    } else {
      // render in the camera sidebar
    }
  },
  onScreenSharePermissionUpdated: (peerId, canShare) => {
    // update the share button for this peer
  },
});

await room.join(session.token, session.ice_servers);

// Later, from a click handler:
const display = await navigator.mediaDevices.getDisplayMedia({ video: true });
const screenTrack = display.getVideoTracks()[0];
if ('contentHint' in screenTrack) screenTrack.contentHint = 'detail';

await room.publishTrack('video', screenTrack, { source: 'screen' });
screenTrack.onended = () => room.unpublishTrack('video', { source: 'screen' });

For the bandwidth side, room.pausePeerVideo(peerId) and room.resumePeerVideo(peerId) pause and resume that peer’s camera Consumer on the media server. They skip screen tracks, so you can’t accidentally pause the thing everyone is looking at. room.resumeAllVideos() restores every camera when sharing ends.

The media server also enforces a few rules so your client doesn’t have to:

  • Only one participant can share at a time. A second screen producer is rejected.
  • Hosts can always share. Other participants need permission, which a host grants or revokes with room.grantScreenShare(peerId) and room.revokeScreenShare(peerId).
  • Revoking permission from the active sharer closes their screen producer on the server.
  • Late joiners receive the current permission state when they connect.

If you want the full UI without building it, the hosted meeting page can be embedded in an iframe with allow="display-capture", and it applies all of the above, including the 360p presenter camera and the pause policy. For background on how producers and consumers fit together, see Simplifying WebRTC SFU Orchestration.

Checklist

  • Set contentHint = 'detail' (or 'text') on screen tracks.
  • Ask for 15 fps from getDisplayMedia(), not 30 or 60, unless you are sharing video.
  • Send screen share as one high-bitrate layer. Keep simulcast for cameras.
  • Shrink the presenter’s camera while they share.
  • Pause camera consumers that aren’t on screen, and resume them when they are.

None of these require a new codec or a custom build of libwebrtc. They are settings and a small amount of policy, and together they cover most of the “I can’t read your screen” complaints.

If you’d rather not wire this up yourself, read the CoveKit docs to see how the SDK and hosted meeting page fit into your app.