Screen Sharing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Screen sharing is the feature that turns a video call into a working meeting: a presenter shows a slide deck, a terminal
or a design file to the other participants. In the browser it is one method, navigator.mediaDevices.getDisplayMedia(),
that returns a MediaStream like the one getUserMedia() returns. Everything after that call (adding the track to an
RTCPeerConnection, producing it on an SFU, recording it) is ordinary WebRTC. This page covers the capture call itself,
the rules the browser enforces, how Huddle sends a screen next to the camera, and how to keep text readable.
It was written against the W3C Screen Capture specification, a Working Draft of 2026-08-27 whose own status text says it
is not intended for implementation yet, although getDisplayMedia() has shipped in every major desktop browser for years,
and the Media Capture Stream Content Hints Working Draft of 2025-09-19. The examples were run on Chrome 155. All facts
were checked on 2026-10-24. The camera side of Huddle is on Media Capture and Devices,
and the connection machinery is on Peer Connections and SDP.
What it is and who uses it
Of the three applications in this section, only the videoconference needs it. Huddle offers a Share button in the call
toolbar. Chatterbox could use it for a support agent who wants to look at a customer’s screen, but it would then be a
WebRTC feature bolted on to the chat. Pixel Arena does not share screens. A spectator mode that streams the canvas uses
canvas.captureStream() instead, which needs no picker and no permission.
The user always decides what is shared. When the page calls getDisplayMedia(), the browser shows its own picker with up
to three kinds of source:
-
Entire screen (a monitor), whose
displaySurfacesetting ismonitor. -
Window (one application window),
window. -
Tab (one browser tab),
browser.
A page cannot enumerate those sources, cannot choose one for the user and cannot be told which one was chosen except by
reading the resulting track’s settings. enumerateDevices() does not list screens and devicechange does not fire for
them. While capture runs, the browser shows a persistent indicator and a Stop sharing control that the page cannot hide.
The API
const stream = await navigator.mediaDevices.getDisplayMedia(options);
The promise resolves with a stream that has one video track and, only if the user and the platform allow it, one audio
track. The options object takes these members (Screen Capture, section on DisplayMediaStreamOptions):
| Option | Values | Effect |
|---|---|---|
|
|
|
|
|
Asks for an audio track. The result may still have no audio track. |
|
|
Whether the tab running the page is offered. |
|
|
Shows a control for switching the shared tab while sharing, without a second prompt. |
|
|
Whether entire screens are offered. |
|
|
A hint about offering system audio when a monitor is shared. |
|
|
Which audio is offered for a window. Chrome implements only |
|
a |
Lets the page influence the session after it starts (see below). |
|
|
Chrome-only option that puts the current tab first in the picker. It is not on a standards track and conflicts with |
The displaySurface constraint inside video pre-selects a picker pane (for example window) but never restricts the
choice. All of these are preferences: the specification says options are applied after selection, so that a page cannot
steer the user toward sharing something they did not intend.
Browser support
getDisplayMedia() shipped in Chrome 72, Firefox 66, Safari 13 and Edge 79. It is not available in Chrome for Android or
Firefox for Android, where the method exists in some versions but always rejects with NotAllowedError, and Safari on
iOS added it only in version 27. Audio capture exists only in Chromium browsers (Chrome 74 and later). The finer options
come from Chromium too: surfaceSwitching and selfBrowserSurface from 107, systemAudio from 105, monitorTypeSurfaces
from 119 and CaptureController from 109. Firefox and Safari ignore unknown options, so passing them is harmless,
but a design that depends on them must degrade gracefully. On phones, Huddle hides the Share button when the call to
getDisplayMedia is missing or the user agent reports a mobile device, and points to the
native clients page for broadcast extensions and media projection.
Rules the browser enforces
-
Secure context. Like
getUserMedia(), the method exists only on HTTPS pages and onlocalhost. -
Transient activation. The call must happen while a recent user gesture is active (a click, a key press). Outside one, the promise rejects with
InvalidStateError. The call consumes the activation, so anawaitof something slow before it can lose it: callgetDisplayMedia()first, then do the signalling. -
Focus. The document must be fully active and focused, otherwise
InvalidStateErroras well. -
No persistence. The permission is never remembered. Each share prompts again, and the Permissions API reports only
promptordeniedfordisplay-capture. -
Permissions Policy. The
display-capturefeature defaults to the page’s own origin. A cross-origin iframe needsallow="display-capture"on the frame and the embedding page’s policy must not forbid it.
Errors that Huddle handles (names from the specification and MDN):
| Error | Meaning and Huddle’s reaction |
|---|---|
|
The user cancelled the picker or denied access, or a policy forbids it. Treated as "nothing happened": no message, the button stays as it was. |
|
No user activation, or the document is not focused, or a |
|
No source exists to capture (for example a headless machine). Show "There is nothing to share on this device." |
|
The user chose a source but the OS refused (macOS Screen Recording permission missing, for example). Show the OS-permission hint. |
|
Invalid options: |
|
Anything else. Show a generic failure and offer a retry. |
On macOS, the browser itself needs the Screen Recording permission in System Settings. No web code can detect it except through NotReadableError.
Starting and stopping a share in a mesh call
In Huddle’s mesh mode every participant holds one RTCPeerConnection per remote peer. A screen is sent as a second video
sender next to the camera, so camera and slides appear together and the receiver chooses the layout. Adding a track to a
connection triggers negotiationneeded, and the perfect-negotiation code from
Signaling and Perfect Negotiation sends the new offer.
The class below does four things: asks for the screen from a click handler, labels the track detail, adds it to every
peer connection and caps its bitrate and frame rate, and stops everything when the user clicks the browser’s Stop sharing
button. It also announces the stream id over signalling so the remote side can tell a screen from a camera, since both
arrive as video tracks. Media stream ids travel inside the SDP (a=msid), so the announcement and the track can arrive in
either order.
// screen-share.js - Huddle screen sharing for a mesh call.
// "peers" maps a remote peer id to its RTCPeerConnection; "signal" sends a
// JSON message to the signalling server (see the Huddle signalling page).
export const SHARE_OPTIONS = {
video: { displaySurface: "monitor", frameRate: { ideal: 15, max: 30 } },
audio: false,
selfBrowserSurface: "exclude",
surfaceSwitching: "include",
monitorTypeSurfaces: "include",
};
export class ScreenShare {
#stream = null;
#senders = new Map(); // peerId -> RTCRtpSender
constructor(peers, signal) {
this.peers = peers;
this.signal = signal;
}
get active() {
return this.#stream !== null;
}
// Must be called from a click handler: getDisplayMedia needs transient activation.
async start(options = SHARE_OPTIONS) {
if (this.#stream) return this.#stream;
let stream;
try {
stream = await navigator.mediaDevices.getDisplayMedia(options);
} catch (err) {
if (err.name === "NotAllowedError") return null; // picker dismissed or denied
throw err; // InvalidStateError (no user gesture), NotFoundError, TypeError...
}
const [track] = stream.getVideoTracks();
track.contentHint = "detail"; // keep text sharp: resolution before frame rate
track.addEventListener("ended", () => this.stop(), { once: true });
this.#stream = stream;
for (const [peerId, pc] of this.peers) {
// A second video sender: the camera keeps running next to the screen.
const sender = pc.addTrack(track, stream);
this.#senders.set(peerId, sender);
await this.#tune(sender);
// The stream id travels in the SDP (a=msid), so the peer can match it
// against this announcement whichever of the two arrives first.
this.signal({ type: "screen", action: "start", to: peerId, streamId: stream.id });
}
return stream;
}
async #tune(sender) {
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
params.encodings[0].maxFramerate = 15;
params.encodings[0].maxBitrate = 2_500_000;
params.degradationPreference = "maintain-resolution";
await sender.setParameters(params);
}
stop() {
if (!this.#stream) return;
const stream = this.#stream;
this.#stream = null;
stream.getTracks().forEach((t) => t.stop()); // stop() does not fire "ended"
for (const [peerId, sender] of this.#senders) {
const pc = this.peers.get(peerId);
if (pc && pc.signalingState !== "closed") pc.removeTrack(sender); // triggers renegotiation
this.signal({ type: "screen", action: "stop", to: peerId, streamId: stream.id });
}
this.#senders.clear();
}
}
Receiving side
The receiver cannot tell from the track alone whether a video track is a camera or a screen. ScreenReceiver decides
by stream id: if the announcement came first the track goes straight to the stage area, otherwise it waits up to one second
for it and falls back to treating it as a camera.
// Receiving sideexport class ScreenReceiver {
#screenStreams = new Set();
#pending = new Map(); // streamId -> MediaStream waiting for its announcement
constructor({ onScreen, onCamera, onScreenEnded }) {
Object.assign(this, { onScreen, onCamera, onScreenEnded });
}
// Wire to pc.ontrack
handleTrack({ track, streams: [stream] }) {
if (track.kind !== "video" || !stream) return;
if (this.#screenStreams.has(stream.id)) this.onScreen(stream);
else {
this.#pending.set(stream.id, stream);
// Fallback: no announcement within 1 s means a camera.
setTimeout(() => {
if (this.#pending.delete(stream.id)) this.onCamera(stream);
}, 1000);
}
}
// Wire to the signalling message handler
handleSignal(msg) {
if (msg.type !== "screen") return;
if (msg.action === "start") {
this.#screenStreams.add(msg.streamId);
const stream = this.#pending.get(msg.streamId);
if (stream) {
this.#pending.delete(msg.streamId);
this.onScreen(stream);
}
} else {
this.#screenStreams.delete(msg.streamId);
this.onScreenEnded(msg.streamId);
}
}
}
Wire it up with pc.ontrack = (event) => receiver.handleTrack(event) and call receiver.handleSignal(message) for every
signalling message of type screen. The screen message is Huddle’s own and not part of the Chatterbox wire protocol,
which Message Design and Serialization defines for chat.
Checked by a loopback test in Chrome 155 (two connections in one page, fake camera, the first screen offered by the
picker): the receiver got the camera and the screen stream, the sender had two video senders, the receiver decoded
more than 100 screen frames, and stopping the share ended the track and produced a stop announcement.
Second track or replaceTrack
There are two ways to put a screen into a call, and they behave differently.
Add a second track (above). The camera keeps streaming, the connection renegotiates once to add an m-line (and once
more, when removeTrack removes the sender, to turn it into inactive), and the receiver can lay the two out
independently. Bandwidth rises by the screen stream. This is the default for Huddle’s rooms because participants want to
see the presenter and the slides.
Replace the camera track. RTCRtpSender.replaceTrack() swaps what an existing sender transmits without renegotiation,
and the sender keeps its SSRC and m-line. It is attractive for 1:1 calls and for phones: instant start, no offer/answer round
trip, no extra bandwidth. The cost is that the camera disappears while sharing, and the receiver cannot tell the
difference, so the application must announce the switch itself if the remote UI should react.
// swap.js - 1:1 Huddle call: show the screen in the camera's video sender.
export function createSwapper(pc, cameraTrack) {
const sender = pc.getSenders().find((s) => s.track === cameraTrack);
if (!sender) throw new Error("camera sender not found");
let screenTrack = null;
async function start() {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
[screenTrack] = stream.getVideoTracks();
screenTrack.contentHint = "detail";
screenTrack.addEventListener("ended", stop, { once: true });
await sender.replaceTrack(screenTrack); // no renegotiation, same SSRC and m-line
}
async function stop() {
if (!screenTrack) return;
screenTrack.stop();
screenTrack = null;
await sender.replaceTrack(cameraTrack);
}
return { start, stop };
}
Source: MDN: RTCRtpSender.replaceTrack()
Checked in Chrome 155: with a loopback pair, the number of negotiationneeded events did not change when the swap
happened, the decoded frame count on the receiver kept growing with the screen as the source, and stop() restored the
camera as the sender’s track. replaceTrack() requires that the new track be compatible with the negotiated codec, which
is always true for a video track in the same direction.
In SFU mode (see Topologies: Mesh, SFU and MCU) the screen is
always a second producer: with mediasoup the client calls transport.produce()
with appData: { source: 'screen' }, and with LiveKit the client calls
room.localParticipant.setScreenShareEnabled(true), and shared tab audio is published as a separate screen_share_audio track. The SFU then forwards the track to everyone
and the stage area is driven by that metadata instead of an application-specific message.
Keeping text readable
A screen is not a camera. It contains sharp edges, small text and long periods without change, then a sudden full-screen change when the presenter switches slides. Encoders tuned for faces blur text if they treat it as motion. Three settings help, and Huddle uses all of them.
contentHint. The Content Hints specification defines motion, detail and text for video tracks. detail and
text ask the sender to prefer per-frame quality over smoothness and, unless degradationPreference is set explicitly,
map to maintain-resolution: under bandwidth pressure the sender drops frames instead of shrinking the picture. text
additionally enables AV1 text-mode tools. motion is the right choice when sharing a video or a game. Setting the hint is
one assignment on the track, as track.contentHint = 'detail' in the class above.
degradationPreference. Set it explicitly in RTCRtpSendParameters when you want the behaviour to be independent of
the hint: maintain-resolution, maintain-framerate or balanced.
Encoding parameters. maxFramerate (screens look fine at 5 to 15 frames per second) and maxBitrate set a budget
for the screen so that it does not starve the camera or the audio. The getParameters() / setParameters() round trip
shown in the class is mandatory: modify the object you got and pass the same object back. In Chrome 155 the parameters we
set were visible on the sender afterwards, with degradationPreference reported as maintain-resolution and the
encoding showing maxBitrate 2500000 and maxFramerate 15.
Constraints on the captured track (frameRate, width, height) are the other lever: video: { frameRate: { ideal: 15, max: 30 } }
makes the browser capture fewer frames, which saves CPU before the encoder even starts. Resolution of a screen is the
resolution of the source, scaled down by the browser, so a 4K monitor produces a heavy stream unless maxBitrate and
scaleResolutionDownBy in RTCRtpEncodingParameters are set. Simulcast for screens is discussed in
Codecs, Simulcast and SVC, and the quality indicators in
Stats, Quality and Bandwidth.
Ending a share
A share ends in three ways, and the application must handle all of them.
-
The user clicks Stop sharing in the browser’s own control. The track fires
endedand itsreadyStatebecomesended. This is the case people forget, and it is why theScreenShareclass listens forended. -
The application calls
track.stop(). This ends the track but does not fireended, so the code that stops the track also does the clean-up directly. -
The shared source disappears, for example the shared window is closed. The browser ends the track, and
endedfires as in the first case.
On a mesh connection the clean-up is pc.removeTrack(sender), which sets the transceiver’s direction and renegotiates,
followed by an announcement that the screen has stopped. Do not leave a dead sender in place: the remote side would
show a frozen last frame. With replaceTrack() the clean-up is putting the camera track back into the sender.
When the user selects a different tab through the surfaceSwitching control, the track is the same object and does not
fire ended. The displaySurface setting stays accurate and the video simply changes content.
Audio
Presenters often want to share the sound of a video they play. The platform limits this strongly:
-
Only Chromium browsers can return audio from
getDisplayMedia(), since Chrome 74. Firefox and Safari return video only. -
On Windows and ChromeOS, sharing a whole screen can include system audio. On macOS and Linux Chrome offers audio only from a shared tab.
-
The call can succeed without an audio track even when you ask for one. Always check
stream.getAudioTracks().length. -
systemAudio: 'exclude'removes the "share system audio" checkbox for screens.suppressLocalAudioPlayback: truein the audio constraints mutes the captured tab for the presenter, which prevents an echo when the presenter’s own speakers play the shared audio.
Use contentHint = 'music' on a shared audio track if the audio is music, so the browser does not apply the speech
processing described in Media Capture and Devices.
Screen audio is a third stream in the call: send it as a separate sender and mix it for recording.
CaptureController
Chrome 109 added CaptureController, an experimental interface in the specification that is passed in the options. It
exists because opening the share can move focus to the shared tab or window, which would take the presenter away from the
call page:
const controller = new CaptureController();
controller.setFocusBehavior('no-focus-change'); // or focus-capturing-application, focus-captured-surface
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, controller });
setFocusBehavior() must be called before the focus decision is finalized, and throws
InvalidStateError afterwards, for a surface that is not a tab or window, or when the source has stopped. A controller is
bound to one stream and cannot be reused. Chrome also exposes zoom controls on the controller (increaseZoomLevel(),
decreaseZoomLevel(), resetZoomLevel(), zoomLevel, getSupportedZoomLevels() and a zoomlevelchange event) and
forwardWheel() for scrolling the shared surface from the call page. Treat all of it as experimental and feature-detect
with typeof CaptureController === 'function'. Checked in Chrome 155: passing a fresh controller in the options
succeeded and returned a video track.
Source: MDN: CaptureController
Embedding Huddle in an iframe
A partner site that embeds the Huddle call page needs two things. The embedding page declares which origin may share, and
the frame carries the allow attribute. The embedded document is the one that calls getDisplayMedia():
<iframe
src="https://huddle.example.com/room/standup"
allow="camera; microphone; display-capture"
title="Huddle stand-up call"></iframe>
If the embedding page also sends a Permissions-Policy header, it must not exclude the feature. The default for
display-capture is self, so a page that sets nothing cannot delegate it without allow.
Permissions-Policy: camera=(self "https://huddle.example.com"), microphone=(self "https://huddle.example.com"), display-capture=(self "https://huddle.example.com")
Common pitfalls
-
Calling the method from a timer, a WebSocket message or after a slow
awaitloses the user activation and fails withInvalidStateError. Start the capture from the click, then do the rest. -
Forgetting
ended. Without the listener, the Share button stays in the "sharing" state after the user stops the share from the browser UI. -
Sharing the call itself. Without
selfBrowserSurface: 'exclude', a presenter who picks the Huddle tab sees an infinite tunnel. Excluding it is the better default for conferencing, and the picker still allows other tabs. -
Treating
NotAllowedErroras a failure. A cancelled picker produces it too. Showing an error toast every time a user changes their mind is annoying. -
Mixing up camera and screen at the receiver. Two video tracks look the same unless the application labels them. Label by stream id, by
mid, or by SFU metadata. -
Letting the screen steal bandwidth. An uncapped 4K share can use several megabits per second. Set
maxBitrateandmaxFramerate, and lower them when the stats show packet loss. -
Assuming audio. Check for the track and tell the presenter when none arrived.
-
Assuming a picker preference is binding.
displaySurface,systemAudioand similar options are hints that Firefox and Safari ignore. -
Recording. A share recorded locally with
MediaRecordercaptures what the presenter sees, so pair it with the guidance in Recording and Media Processing.
Security and privacy
The main risk is accidental disclosure: a notification, a password manager window or a private tab ends up in the share. The browser mitigations are visible indicators, the per-share prompt, hiding a hidden or obscured window’s content where it can, and a warning when a browser window is chosen. Applications add their own:
-
Prefer tab or window sharing in the UI copy and reserve full-screen sharing for when it is needed.
-
Show a prominent "You are sharing your screen" banner in the call, in addition to the browser’s indicator.
-
Never start a share without a click and never re-share after a reconnect without asking again. The browser enforces the first, and the second is a matter of not caching streams across sessions.
-
In a hosted product, restrict who may share through the server (Huddle’s signalling server checks a
canSharepermission before relaying thescreenmessage) so that a malicious client cannot announce a fake screen. -
Treat the captured content as sensitive data: encrypt recordings and consider end-to-end encryption as described in End-to-End Encryption.
-
Embed only trusted origins with
allow="display-capture".
Version and specification history
-
Before
getDisplayMedia(), Firefox 33 to 65 usedgetUserMedia()with amediaSourcevideo constraint, and Edge 17 to 78 had a partial version onNavigator. -
Chrome 72, Firefox 66 and Safari 13 shipped
getDisplayMedia()on theMediaDevicesinterface. Edge has it since the Chromium-based Edge 79. -
Chrome added
audioin 74,preferCurrentTabin 94,systemAudioin 105,selfBrowserSurfaceandsurfaceSwitchingin 107,CaptureControllerin 109 andmonitorTypeSurfacesin 119. -
The Screen Capture specification is a W3C Working Draft of 2026-08-27 and still describes itself as not ready for implementation. The content hints specification is a Working Draft of 2025-09-19.
-
Chrome 141 implemented a partial
windowAudio. Zoom control andforwardWheel()onCaptureControllerare Chromium-only.