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 displaySurface setting is monitor.

  • 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

video

true (default) or constraints

false rejects with TypeError. Constraints such as frameRate, width, height and displaySurface are applied after the user chose a source, and min and exact values are rejected with TypeError.

audio

false (default), true or constraints

Asks for an audio track. The result may still have no audio track.

selfBrowserSurface

include, exclude

Whether the tab running the page is offered. exclude avoids the "hall of mirrors" in which a presenter shares the call itself.

surfaceSwitching

include, exclude

Shows a control for switching the shared tab while sharing, without a second prompt.

monitorTypeSurfaces

include, exclude

Whether entire screens are offered. exclude combined with displaySurface: monitor is a TypeError.

systemAudio

include, exclude

A hint about offering system audio when a monitor is shared.

windowAudio

system, window, exclude

Which audio is offered for a window. Chrome implements only system and exclude.

controller

a CaptureController

Lets the page influence the session after it starts (see below).

preferCurrentTab

true, false

Chrome-only option that puts the current tab first in the picker. It is not on a standards track and conflicts with selfBrowserSurface: exclude.

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 on localhost.

  • 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 an await of something slow before it can lose it: call getDisplayMedia() first, then do the signalling.

  • Focus. The document must be fully active and focused, otherwise InvalidStateError as well.

  • No persistence. The permission is never remembered. Each share prompts again, and the Permissions API reports only prompt or denied for display-capture.

  • Permissions Policy. The display-capture feature defaults to the page’s own origin. A cross-origin iframe needs allow="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

NotAllowedError

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.

InvalidStateError

No user activation, or the document is not focused, or a CaptureController was reused. A programming error: log it.

NotFoundError

No source exists to capture (for example a headless machine). Show "There is nothing to share on this device."

NotReadableError

The user chose a source but the OS refused (macOS Screen Recording permission missing, for example). Show the OS-permission hint.

TypeError

Invalid options: video: false, an exact constraint, or the monitor conflict. A programming error.

AbortError

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 };
}

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.

  1. The user clicks Stop sharing in the browser’s own control. The track fires ended and its readyState becomes ended. This is the case people forget, and it is why the ScreenShare class listens for ended.

  2. The application calls track.stop(). This ends the track but does not fire ended, so the code that stops the track also does the clean-up directly.

  3. The shared source disappears, for example the shared window is closed. The browser ends the track, and ended fires 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: true in 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.

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 await loses the user activation and fails with InvalidStateError. 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 NotAllowedError as 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 maxBitrate and maxFramerate, 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, systemAudio and similar options are hints that Firefox and Safari ignore.

  • Recording. A share recorded locally with MediaRecorder captures 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 canShare permission before relaying the screen message) 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 used getUserMedia() with a mediaSource video constraint, and Edge 17 to 78 had a partial version on Navigator.

  • Chrome 72, Firefox 66 and Safari 13 shipped getDisplayMedia() on the MediaDevices interface. Edge has it since the Chromium-based Edge 79.

  • Chrome added audio in 74, preferCurrentTab in 94, systemAudio in 105, selfBrowserSurface and surfaceSwitching in 107, CaptureController in 109 and monitorTypeSurfaces in 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 and forwardWheel() on CaptureController are Chromium-only.