Screen share blurry/soft on subscribers — sharpens after several seconds. Looking for the right knobs (livekit-client 2.16.0, simulcast + adaptiveStream)

Problem

Shared screens (mostly slides/documents, i.e. text-heavy static content) arrive soft/blurry on subscribers, then sharpen after a few seconds. The worst of it is right at the start of a share. Text is the main casualty — it’s legible after it settles, but not during the ramp.

Environment

  • livekit-client 2.16.0
  • @livekit/components-react 2.9.16
  • livekit-server-sdk 2.14.2
  • Next.js 15.2.6 / React 18.3.1
  • Deployment: (Cloud / self-hosted + server version — fill in)
  • Sharers: (browser + OS — fill in)

Current config (screen-share options intentionally left at SDK defaults)

const publishDefaults: TrackPublishDefaults = {
  dtx: true,
  videoSimulcastLayers: hq
    ? [VideoPresets.h540, VideoPresets.h360]
    : [VideoPresets.h216, VideoPresets.h90],
  red: !e2eeEnabled,
  videoCodec, // 'vp8' by default
  // screenShareEncoding / screenShareSimulcastLayers / degradationPreference
  // are NOT set — relying on SDK defaults
};

return {
  videoCaptureDefaults,
  publishDefaults,
  adaptiveStream: true,
  dynacast: true,
  singlePeerConnection: true,
};

Receive side, we already try to pin the focused share to the top layer:

// runs when the focused track changes or becomes subscribed
publication.setVideoQuality(VideoQuality.HIGH);

What I’ve already worked out from the SDK source

  1. Screen share already gets maintain-resolution for free:
opts.degradationPreference ??= getDefaultDegradationPreference(track);
// returns 'maintain-resolution' for Track.Source.ScreenShare

Because of the ??=, setting degradationPreference in publishDefaults overrides this per-source logic for both camera and screen share. I tried 'maintain-framerate' and it made things clearly worse (it trades resolution for fps — exactly wrong for text). So I’ve left it unset.

  1. Default screenShareEncoding is ScreenSharePresets.h1080fps15.encoding → 1920×1080, 2.5 Mbps, 15 fps.

  2. When I set screenShareSimulcastLayers: [h360fps15, h1080fps15], the ladder builder hits:

if (size >= 960 && midPreset) {
  return encodingsFromPresets(width, height, [lowPreset, midPreset, original], ...);
}

i.e. three encodings (360p + 1080p + original). That seemed to make the startup ramp worse (bitrate split three ways) and left a 360p floor that adaptiveStream can park a subscriber on.

Questions

  1. Startup ramp: is there a supported way to make a new screen-share track reach its target bitrate faster? The first seconds look like BWE probing upward while maintain-resolution holds full resolution at high QP. Is there a recommended startBitrate / probing approach, or is this simply expected WebRTC behaviour?

  2. Simulcast for screen share: for text-heavy content, is a single layer (no simulcast for screenshare) the recommended setup, or is there a preferred ladder that avoids a low floor? If single-layer is preferred, what’s the clean way to disable simulcast only for screen share (publishDefaults.simulcast looks global)?

  3. adaptiveStream vs setVideoQuality: does adaptiveStream re-evaluate and override a manual setVideoQuality(HIGH) after the element is measured? Our tile has no measured size on first paint, so I suspect it starts on the lowest layer and gets promoted late. Is there a supported way to exempt a specific publication from adaptive-stream quality management?

  4. Codec: for screen share specifically, is VP9/AV1 meaningfully better here? I noticed the SDK forces contentHint = 'motion' for screenshare with SVC codecs (the L1T3 workaround), and the comment says that overrides degradation preference to balanced — does that hurt text sharpness compared to staying on VP8?

  5. Is setting contentHint to 'detail'/'text' on the screen-share track recommended/supported here, and does it conflict with the SVC workaround above?

What is the proper solution for this issue?

@meghana_reddy Your three source readings are all correct. The useful part is that your first three questions collapse into one cause, and it’s question 3.

adaptiveStream does override setVideoQuality(HIGH). In RemoteTrackPublication.emitTrackUpdate(), when adaptive dimensions exist it compares them against the layer your requested quality maps to and takes the smaller:

  } else if (this.requestedMaxQuality !== undefined && this.trackInfo) {
    const maxQualityLayer = layerDimensionsFor(this.trackInfo, this.requestedMaxQuality);
    if (maxQualityLayer && areDimensionsSmaller(this.videoDimensionsAdaptiveStream, maxQualityLayer)) {
      minDimensions = this.videoDimensionsAdaptiveStream;
    }
  }

So an unmeasured tile on first paint produces near-zero dimensions, those win over HIGH, and you get the bottom layer until the element measures and promotes you. That is your ramp. setVideoDimensions() doesn’t escape it either, it’s min’d the same way.

And you have a 540p floor by default, without setting screenShareSimulcastLayers at all. computeDefaultScreenShareSimulcastPresets builds one layer at scaleResolutionDownBy: 2, so from h1080fps15 you get 960×540 at 625 kbps alongside the 1080p original. That’s the layer adaptiveStream parks you on, and
540p of text upscaled is exactly “soft”.

Removing the floor is cleaner than you’d expect. sortPresets only bails on undefined, so an empty array survives, presets.length > 0 is false, and you fall through to a single encoding:

screenShareSimulcastLayers: [ ]

That gives you 1080p only, no floor to get stuck on, and no need to touch publishDefaults.simulcast, which is global as you noted. The whole bitrate goes to one layer instead of splitting three ways.

On the startup ramp itself, holding full resolution at high QP while BWE probes is ordinary WebRTC behaviour and maintain-resolution is the right call for text. Dropping the low layer helps more than chasing it, since you stop starting from 540p.

I haven’t verified the codec and contentHint questions, so I’d rather not guess on those two.