Skip to main content

Stream tuning · why high-motion looks laggy

If you've shared a screen of a game (or a video, or anything that moves a lot) over rtc.io and the receiver sees a soft, low-frame-rate, slightly-behind picture, that's not rtc.io misbehaving — it's the browser's default capture and encode settings doing exactly what they were designed to do: optimise for an IDE, a slide deck, a call where one face is talking. Those defaults are wrong for high-motion content, and there are four knobs you turn to fix it.

The four knobs

1 · Capture frame rate (getDisplayMedia constraints)

Browsers cap captured screen frame rate to 30 fps or lower when you call getDisplayMedia({ video: true }) with no constraint. The OS only sends frames at that rate; the encoder can't make up frames it never received.

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

ideal is a hint, not a requirement — the browser may clamp to 30 if the OS / window manager won't deliver more. Chromium on macOS in particular caps at 30 fps for screen capture; tab capture runs at 60.

2 · Track contentHint

A WebRTC video encoder has to choose between frame rate and per-frame detail under any given bitrate budget. The contentHint property tells it which to favor:

HintEncoder bias
'motion'Prefer frame rate; tolerate softer details. Right for games, video, animations.
'detail' / 'text'Prefer sharpness; tolerate lower frame rate. Right for IDEs, slide decks, design tools.
'speech'(audio) Voice-tuned codec settings. Right for calls.
'music'(audio) Preserves dynamic range. Right for game audio, system audio capture.
const [video] = stream.getVideoTracks();
(video as MediaStreamTrack & { contentHint?: string }).contentHint = 'motion';

Setting the hint is one line and is the single highest-leverage change for "why does my game look bad".

3 · Encoder maxBitrate via setParameters

Even after you ask for 60 fps and tell the encoder to favor motion, Chromium caps the outgoing video bitrate at around 2.5 Mbps for screen capture by default. That's enough for a 1080p slide deck and visibly insufficient for 1080p60 game footage.

You raise it with RTCRtpSender.setParameters:

import { socket } from "rtc.io";

// rtc.io exposes the underlying RTCPeerConnection per peer:
const conn = socket.getPeer(peerId)?.connection;
if (!conn) return;

for (const sender of conn.getSenders()) {
if (sender.track?.kind !== "video") continue;
// (filter to the screen-share track if you have multiple)

const params = sender.getParameters();
params.encodings = (params.encodings ?? [{}]).map((enc) => ({
...enc,
maxBitrate: 8_000_000, // 8 Mbps — comfortable for 1080p60 motion
maxFramerate: 60,
}));
await sender.setParameters(params);
}

This is per-peer — you call it once for every connected peer, and once more in your peer-connect handler so the cap is also applied when a new peer joins mid-share. The reference demo (rtcio-web) does this in startScreenShare().

How high to go:

Resolution + framerateComfortable cap
720p30 (slides, IDE)1.5–2.5 Mbps (default is fine)
720p60 (light motion)4 Mbps
1080p30 (HD video)4–5 Mbps
1080p60 (games, animation)6–10 Mbps
1440p6012–18 Mbps
4K3020–35 Mbps

The browser still adapts downward when the network is congested; maxBitrate is a ceiling, not a floor.

4 · Audio DSP for non-voice sources

When you getDisplayMedia({ audio: true }) (system audio capture), the browser runs the same voice-call DSP chain by default — noise suppression, echo cancellation, automatic gain control. That chain crushes game sound effects, music, and explosions because it was tuned to suppress everything that isn't a single human voice.

const stream = await navigator.mediaDevices.getDisplayMedia({
video: { /* ... */ },
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});

stream.getAudioTracks().forEach((t) => {
(t as MediaStreamTrack & { contentHint?: string }).contentHint = 'music';
});

For voice (getUserMedia with mic), keep the DSP on — that's what it's for.

Other things that look like rtc.io lag but aren't

SymptomLikely causeFix
Smooth start, gets choppy after a few secondsBandwidth ramp-down — congestion control thinks the link is congestedOpen chrome://webrtc-internals and check availableOutgoingBitrate for the screen sender. If it's collapsing, the bottleneck is the network.
Laggy in one direction onlyAsymmetric upload/downloadThe sender's upload is the cap. Wired Ethernet >> Wi-Fi for screen share.
Constant low fps no matter whatBrowser fell back to software encoderCheck chrome://gpu — hardware video encode should be enabled. Some OS configurations disable it.
Receiver buffers a lotReceiver is decoding on CPUSame: hardware decode on the receiver side.
Goes through a TURN serverRelayed traffic adds latency + bandwidth costCheck getStats()candidate-pair[type] should be host or srflx, not relay.

What rtc.io itself does and doesn't do here

rtc.io owns:

  • The signaling pattern, perfect negotiation, ICE restart, glare resolution.
  • The DataChannel layer (backpressure, broadcast/per-peer matching).
  • The lifecycle model (peer-connect / peer-disconnect / track-added).

rtc.io deliberately doesn't own:

  • The encoder. That's the browser. We don't transcode; we don't recompress; we don't insert frames.
  • The capture pipeline. getUserMedia / getDisplayMedia are the platform's. The constraints you pass go to the OS.
  • Codec selection. The browser negotiates the codec list with the remote peer.

Tuning streams is therefore mostly about passing the right hints to the platform — capture constraints, contentHint, setParameters. rtc.io exposes the underlying RTCPeerConnection via socket.getPeer(peerId).connection precisely so you have full access to the WebRTC stats and parameter APIs without rtc.io standing in the way.

A worked snippet, end-to-end

The four knobs together. Treat this as a starting point — every value here is worth profiling against your own network and content type before you ship. The reference demo at rtcio.dev deliberately uses the plain getDisplayMedia({ video: true, audio: true }) defaults so the path through the library matches what most apps will do on day one; reach for these knobs when you've got a concrete quality complaint to fix.

const raw = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: 60, max: 60 }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
});

raw.getVideoTracks().forEach((t) => ((t as any).contentHint = 'motion'));
raw.getAudioTracks().forEach((t) => ((t as any).contentHint = 'music'));

const screen = new RTCIOStream(raw);
socket.emit('screenshare', { id: socket.id, name: userName, stream: screen });

// Bump every existing peer's outgoing screen-share encoder.
queueMicrotask(() => {
for (const p of peers) {
const conn = socket.getPeer(p.id)?.connection;
if (!conn) continue;
for (const sender of conn.getSenders()) {
if (sender.track?.kind !== 'video') continue;
if (!raw.getVideoTracks().some((t) => t.id === sender.track?.id)) continue;
const params = sender.getParameters();
params.encodings = (params.encodings ?? [{}]).map((enc) => ({
...enc, maxBitrate: 8_000_000, maxFramerate: 60,
}));
sender.setParameters(params).catch(() => {});
}
}
});

// Same path runs again on `peer-connect` so the cap also applies to
// peers that join mid-share.

That's the whole "make screen sharing not look like 2014 Skype" recipe.