Display Capture: Screen Sharing

WebRTC streams a screen the same way it streams a camera. The pixels arrive as a MediaStreamTrack, you add that track to an RTCPeerConnection, and the peer renders it. What changes is where the pixels come from and how the user grants access.

The entry point is navigator.mediaDevices.getDisplayMedia(). It returns a MediaStream whose video track carries the contents of a monitor, a window, or a browser tab. From there the path is ordinary WebRTC: one track, one peer connection, one RTCDataChannel next to it if you also send game or chat data.

This guide covers getDisplayMedia in depth: how it differs from getUserMedia, the surface types it exposes, every constraint it accepts, the gesture and secure-context rules that gate it, and how to wire the resulting track into a peer connection. Audio capture, the share lifecycle, and combining a camera with a screen each have their own guides linked at the end.

getDisplayMedia in one call

A single asynchronous call captures a display surface.

const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const [track] = stream.getVideoTracks();

The call always opens a picker. The browser shows its own dialog listing monitors, open windows, and (in Chromium) browser tabs. You do not choose the surface in code. The user does. The promise resolves only after the user picks something and confirms; it rejects if they cancel.

That single fact shapes everything else. You cannot pre-select a screen, you cannot remember a previous choice, and you cannot capture silently in the background. Every share starts with an explicit human action.

getDisplayMedia({ video }) Browser picker Monitor Window Tab User selects a surface Promise resolves → MediaStream stream.getVideoTracks()[0] pc.addTrack(track, stream)

The picker itself is browser chrome you cannot style, reposition, or read. You do not know which surfaces it lists, how the user navigates it, or how long they take. Your code sees two outcomes: a resolved promise with a stream, or a rejected promise when they cancel. Build the surrounding UI around that binary. Show a clear "Share screen" affordance, and treat the time between the click and the resolution as an open-ended wait rather than an instant transition.

The schematic below traces the full round trip: the click that starts the share, the OS dialog, the returned stream, the track swap onto the peer connection, and the eventual stop.

User BrowserAPI RTCPeer-Connection Clicks "Share screen" Prompts OS permission dialog Selects "Window" Returns MediaStream replaceTrack(screenTrack) Clicks OS "Stop sharing" Fires 'track.onended' replaceTrack(cameraTrack) Streaming screen…

How it differs from getUserMedia

getDisplayMedia and getUserMedia share a return type and a method family. They differ in who chooses the source and how access is granted.

getUserMedia reads cameras and microphones. You can enumerate devices with enumerateDevices(), then request a specific one by deviceId. The browser may grant access without a fresh prompt once the user has approved the origin. Permission is persistent and per-origin.

getDisplayMedia reads displays. There is no device list. You cannot pass a deviceId, and enumerateDevices() never returns monitors or windows. The browser always shows its picker, every time, and never remembers the choice. Permission is per-call and ephemeral.

Aspect getUserMedia getDisplayMedia
Source Cameras, microphones Monitors, windows, tabs
Source selection Your code (deviceId) or browser default User, through the browser picker
Enumerable Yes, via enumerateDevices() No
Prompt Once per origin, then remembered Every call
Persistent permission Yes No
Permissions API name camera, microphone display-capture (query only, gesture still required)
Transient activation Not required after grant Required on every call

The practical consequence: you build a "Share screen" button, not a "resume last share" feature. Treat each capture as a one-off the user initiates by hand.

This is a deliberate privacy boundary, not an oversight. A screen can show anything: passwords, private messages, other people's data. The browser refuses to let a page reach for that without a fresh, explicit choice each time. There is no API that bypasses the picker, no allowlist that pre-authorizes an origin, and no way to keep a share alive across a reload. A page reload drops the track and forces a new gesture and a new pick.

It also means you cannot probe what displays exist. With a camera you can count devices, label them, and build a custom selector. With a display you get nothing until the user picks, and even then you learn only the chosen surface's settings, never the list of what was on offer. Design the UI around a single button and the browser's own dialog, not a device dropdown of your own.

The picker and the surface types

The picker groups sources into three kinds of display surface.

displaySurface value What it captures Notes
monitor An entire physical display Largest resolution; includes everything on that screen, including notifications
window A single application window Follows the window; clipped to its bounds; goes black if minimized
browser A single browser tab Chromium only; can include tab audio; lowest privacy exposure

You request a preference, not a guarantee. The displaySurface constraint hints which kind to show first or emphasize, but the user can still pick any available surface. Read what they actually chose from the track's settings.

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: { displaySurface: 'monitor' },
});
const [track] = stream.getVideoTracks();
const settings = track.getSettings();
console.log(settings.displaySurface); // 'monitor' | 'window' | 'browser'

getSettings() reports the truth after the fact. The displaySurface field tells you whether the user shared a whole monitor, one window, or a tab. Branch on it when behaviour should change: a monitor share may carry private content from other apps, while a tab share is tightly scoped.

Firefox historically used monitor / window / browser plus application for grouped windows. Treat any unexpected string as a non-tab surface rather than asserting an exact match.

What each surface exposes

The three surface kinds differ in privacy reach, and that difference should inform which one you nudge toward.

A monitor share is the broadest. It streams everything on that physical display: your app, other apps, the taskbar, and any notification that pops up mid-session. A banner from a chat app or a calendar reminder lands in the stream. Warn users before a full-monitor share, and prefer it only when the user genuinely needs to show their whole desktop.

A window share is scoped to one application window. The stream follows that window as it moves and resizes, and clips to its current bounds. Content from other windows stacked on top is not captured; the shared window's own pixels are. A minimized window emits no frames and the peer sees black until it is restored.

A browser tab share is the tightest scope and the only surface that cleanly carries tab audio. It streams one tab's rendered content and nothing else, so notifications and other tabs stay private. For a co-browsing or presentation tool, a tab share leaks the least.

When your app only needs to show a document or a tab, steer the picker away from whole-monitor capture with monitorTypeSurfaces: 'exclude'. Less surface offered means less accidental exposure.

Multiple monitors

A user with two displays still shares one surface per getDisplayMedia call. Picking a monitor captures that one monitor, not the combined desktop. To stream two monitors you make two calls, get two tracks, and add both to the connection as separate senders. There is no single "all displays" surface, and the picker lists each physical monitor individually.

Constraints

getDisplayMedia accepts a MediaStreamConstraints object. The video member is required for a screen share; audio is optional and covered in the audio guide.

const constraints = {
  video: {
    frameRate: { ideal: 15, max: 30 },
    width: { max: 1920 },
    height: { max: 1080 },
    cursor: 'motion',
    displaySurface: 'monitor',
  },
  audio: false,
};

const stream = await navigator.mediaDevices.getDisplayMedia(constraints);

Display constraints behave differently from camera constraints. A camera has discrete capabilities you can match exactly. A display does not. You are capturing whatever the user picks at whatever size it happens to be. So width, height, and frame rate act as upper bounds the browser may downscale to, not exact targets it will produce.

Constraint Type Effect
frameRate number / { ideal, max } Caps capture rate; static screens emit fewer frames regardless
width, height number / { max } Treated as maximums; the browser downscales large surfaces to fit
cursor 'always' / 'motion' / 'never' Whether and when the pointer is drawn into the frame
displaySurface 'monitor' / 'window' / 'browser' Hints which surface kind the picker emphasizes
logicalSurface boolean Capture the logical (full) surface rather than only the visible region
surfaceSwitching 'include' / 'exclude' Show or hide an in-share control to switch the captured surface
selfBrowserSurface 'include' / 'exclude' Allow or block sharing the current tab (the one running your code)
monitorTypeSurfaces 'include' / 'exclude' Offer or suppress whole-monitor options in the picker
preferCurrentTab boolean Bias the picker toward sharing the current tab

Frame rate and resolution as hints

Set frameRate low for screen content. Text and slides change rarely, so a high frame rate wastes bandwidth and CPU on duplicate frames. An ideal of 5 to 15 suits documents and presentations; raise it only for video playback or animation.

Width and height behave as ceilings. A 4K monitor produces a 3840×2160 surface; a width: { max: 1920 } constraint tells the browser to downscale before encoding. Without a ceiling you may send far more pixels than the peer's window can show, burning bandwidth for no visible gain.

The browser never upscales a small window to meet an ideal width. Constraints can only reduce what the surface already provides.

Cursor

cursor controls the pointer.

  • 'always' draws the cursor in every frame. Use it when the pointer matters, such as a teaching or support session.
  • 'motion' draws the cursor only while it moves, then drops it when still. This avoids a frozen arrow over static text.
  • 'never' omits the cursor entirely. Use it when the pointer would distract, such as sharing a finished design.

Surface scoping constraints

The newer constraints shape the picker itself rather than the captured frames.

selfBrowserSurface: 'exclude' removes the current tab from the picker. This is the direct fix for the feedback loop described later: if the user cannot pick the tab that renders the remote video, the loop cannot form.

preferCurrentTab: true does the opposite. It biases toward the current tab for a "share this page" flow, such as a co-browsing tool that only ever shares its own document.

monitorTypeSurfaces: 'exclude' hides whole-monitor options, keeping the user on windows and tabs when full-desktop capture is more exposure than your app needs.

surfaceSwitching: 'include' adds a browser control that lets the user change the captured surface mid-share without a new getDisplayMedia call. The same track keeps streaming; only its source changes.

These are requests. Support varies by browser, and an unsupported constraint is ignored rather than rejected. Verify behaviour with the screen demo across the browsers you target.

Secure context, permission, and the gesture

Three gates guard the call. All three must pass or the promise rejects.

Secure context. getDisplayMedia exists only on pages served over HTTPS, or on localhost and 127.0.0.1 for development. On a plain http:// origin the API is absent and navigator.mediaDevices may be undefined. This is why local work needs a real server: file:// is not a secure context for this API. Run the project under symfony serve, which provides HTTPS over localhost.

Permission. The browser shows its picker as the permission surface. There is no separate persistent grant to pre-check. You can query the display-capture permission through the Permissions API, but a granted result does not skip the picker; the picker always appears.

Transient activation. The call must run inside a user gesture. A click, key press, or tap arms a short-lived activation window; getDisplayMedia consumes it. Call it directly in the handler.

shareButton.addEventListener('click', async () => {
  // Inside the gesture: transient activation is live.
  try {
    const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
    startSharing(stream);
  } catch (err) {
    // User cancelled the picker, or a gate failed.
    if (err.name === 'NotAllowedError') {
      // Cancelled or blocked; not a crash.
      return;
    }
    console.error('Screen capture failed', err);
  }
});

Activation is spent by the time an await elsewhere resolves. If you await something before calling getDisplayMedia, the gesture may have expired and the call rejects with InvalidStateError. Call it first, then await its result, then do follow-up work.

A user who cancels the picker triggers a rejection with NotAllowedError. Cancellation is the normal "no thanks," not an error to surface loudly. Distinguish it from real failures and stay quiet when the user simply backs out.

Querying display-capture permission

The Permissions API exposes a display-capture name, but it behaves unlike camera or microphone.

const status = await navigator.permissions.query({ name: 'display-capture' });
console.log(status.state); // 'granted' | 'prompt' | 'denied'

A granted state does not let you skip the picker. The picker is the choice of surface, not only the grant of permission, so it always appears. A denied state, set by the user or by a policy on the origin, means getDisplayMedia will reject without ever showing the picker. Use the query to detect that blocked case up front and disable the share button with an explanation, rather than letting the click fail silently. Treat prompt and granted the same: show the button and let the picker run.

Support for the display-capture permission name varies, and query itself may reject with a TypeError where it is unknown. Wrap it and fall back to simply showing the button if the query is unavailable.

Sending the track over a peer connection

A screen track is a MediaStreamTrack. Adding it to a peer connection is identical to adding a camera track.

const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const [screenTrack] = stream.getVideoTracks();

const sender = pc.addTrack(screenTrack, stream);

addTrack registers the track with the RTCPeerConnection and returns an RTCRtpSender. Adding a track changes the set of media the connection offers, so it triggers renegotiation: negotiationneeded fires, you create a new offer, and you exchange it with the peer through your signaling path. In this project signaling carries only SDP, never frames, and the modes live in src/salon/ (manual paste, URL hash, ntfy.sh pub/sub). The video itself flows peer-to-peer over the media transport, never through a server.

A screen share usually rides alongside other traffic on the same connection. A data channel carrying chat or game state, a camera track, an audio track: all share one RTCPeerConnection and one negotiated transport. Adding the screen track does not disturb the existing channels; it adds a new media section and renegotiates only that. The data channel keeps flowing through the swap. Because the media path is peer-to-peer over public STUN with no relay, the screen pixels travel the same direct route as everything else on the connection.

Swapping a camera for a screen without renegotiating

If a connection already sends a camera track, switching it to a screen track does not require a new offer. Replace the track on the existing sender.

const sender = pc.getSenders().find((s) => s.track?.kind === 'video');
await sender.replaceTrack(screenTrack);

replaceTrack swaps the source on the wire without changing the SDP, so no negotiationneeded fires and no offer/answer round trip happens. The peer keeps receiving on the same m= line; only the pixels change. This is the standard way to toggle between camera and screen, and it underpins the camera-plus-screen patterns in the camera and screen guide.

replaceTrack requires a sender that already carries video. If the connection has no video sender yet, use addTrack and accept the renegotiation. The mechanics of senders, transceivers, and track swaps are covered in the camera and screen guide.

One subtlety: replaceTrack swaps the source but keeps the negotiated codec and parameters. A camera track and a screen track use the same encoder slot, so a swap inherits whatever resolution and frame-rate bounds were negotiated for the camera. If those bounds suit a 720p webcam but not a 4K screen, adjust them after the swap with applyConstraints on the new track or by updating the sender parameters. The wire stays the same; only what you feed it changes.

Rendering locally

Show the user what they are sharing. Attach the same stream to a local <video> element and mute it.

localVideo.srcObject = stream;
localVideo.muted = true;

Muting the local element matters most when audio capture is on; it stops the captured audio from playing back through the user's speakers and feeding the loop.

Encoding screen content

Screen pixels are not camera pixels. A camera feed is continuous motion with soft edges and noise; a screen feed is mostly static with hard edges and flat color. The encoder should treat them differently.

contentHint

contentHint tells the encoder what the track contains so it can trade resolution against frame rate sensibly.

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

The values:

contentHint Meaning Encoder bias
'text' Sharp text, code, slides Preserve resolution; drop frame rate first
'detail' Static images, diagrams, spreadsheets Preserve detail over smoothness
'motion' Video playback, animation Preserve frame rate; allow softer frames
'' (default) Unhinted Browser guesses from the source

For documents and code, set 'text'. Under congestion the encoder then drops frames before it blurs pixels, so the screen stays readable even at a few frames per second. Set 'motion' only when sharing a video, where smooth playback beats per-pixel sharpness.

The hint matters because the encoder adapts continuously. WebRTC measures the link and adjusts the encode target in real time. With no hint it guesses, and a screen full of text can come back smeared when the encoder spends its budget chasing a frame rate the content never needed. The hint redirects that budget toward the dimension that matters for the content you are actually sending. Set it once, right after you pull the track from the stream, before adding it to the connection.

Set the hint on the track, not the sender. The track carries the hint into whichever sender it lands on, and it survives a replaceTrack swap onto an existing sender. Re-set it whenever you swap in a new screen track, since a fresh track starts unhinted.

Tightening with applyConstraints

You can adjust the live track after capture without reopening the picker.

await screenTrack.applyConstraints({
  frameRate: { max: 10 },
  width: { max: 1280 },
  height: { max: 720 },
});

applyConstraints re-applies bounds to a running track. Use it to throttle frame rate or cap resolution once you measure the network, or to step down quality when a session degrades. It cannot exceed what the surface provides, and it cannot change the surface the user chose.

Resolution and frame-rate realities

A shared monitor can be very large. A 4K display at full rate is far more data than most peers can decode or display. Cap the dimensions to the size the remote window actually renders, and keep the frame rate low unless the content moves. High resolution with a low frame rate is the right shape for screen content: readable text, modest bandwidth.

Reading what you actually send

Constraints are requests; the encoder decides the rest. To see what the connection is really sending, poll the sender's stats.

const stats = await sender.getStats();
for (const report of stats.values()) {
  if (report.type === 'outbound-rtp' && report.kind === 'video') {
    console.log(report.frameWidth, report.frameHeight, report.framesPerSecond);
  }
}

The outbound-rtp report carries the live frame size and rate after the encoder has applied its own adaptation. When the network tightens, the browser lowers frame rate or resolution on its own to fit the available bandwidth; the stats show the result, and contentHint decides which of the two it sacrifices first. Watch these numbers when a share looks worse than expected before reaching for new constraints.

Why simulcast rarely helps here

Camera streams often use simulcast: the sender encodes several resolutions at once so the connection can pick one per receiver. For a single peer-to-peer screen share there is one receiver, so there is nothing to choose between, and simulcast only adds encode cost. Screen content also compresses unevenly, with long static stretches punctuated by full-frame changes when a slide flips, which fits a single well-hinted stream better than parallel layers. For the one-to-one and host-and-guests topologies in this project, send one stream and tune it with contentHint and constraints.

The Hall of Mirrors

If a user shares the exact browser tab where your app renders the remote video, and you render their screen share back to them, you will create an infinite "inception" loop of mirrors. Always mute the local video element, set selfBrowserSurface: 'exclude' to keep the current tab out of the picker, and consider warning the user if they are sharing the current tab.

When the share ends

A screen share can stop from outside your code. The browser's own "Stop sharing" bar, the OS menu, or closing the shared window all end the capture. The track's ended event is the single signal for all of these.

screenTrack.addEventListener('ended', () => {
  // The browser or OS stopped the share, not your button.
  revertToCamera();
});

Listen for ended and react: swap back to a camera track, update the UI, or tear down the sender. Relying only on your own "stop" button leaves the app out of sync when the user stops from the browser bar instead. The full lifecycle, including stopping cleanly from code and coordinating both ends, is covered in the lifecycle guide.

The ended event fires on the local track only. The peer does not learn the share stopped from the track; it simply stops receiving frames. If the remote side should show a "sharing ended" state, send that as an explicit message over your data channel when ended fires. The track event tells you locally; your protocol tells the peer.

Surface and track edge cases

A few behaviours surprise people the first time they build a share.

The surface can change size mid-stream. A window resize or a monitor resolution change shifts the track's dimensions while it runs. Listen for track resize through the video element or poll getSettings() if your layout depends on the exact size; do not assume the dimensions are fixed for the life of the track.

A window or tab share can outlive what it shows. Closing the shared window or tab ends the track with the same ended event as a manual stop, so one handler covers both. There is no separate "source closed" event to special-case.

Frame rate on a static screen drops to near zero on its own. The browser does not push duplicate frames for an unchanging surface, so framesPerSecond in the stats can read very low during a pause on a slide. That is correct behaviour, not a stall; the peer's last frame stays on screen until something changes.

A getDisplayMedia stream can carry an audio track when the user shares a tab with sound or enables system audio. Always read both getVideoTracks() and getAudioTracks(), and handle the audio track explicitly rather than assuming a screen stream is video-only. Audio capture has its own constraints and pitfalls, covered in the audio guide.

Recap

  • navigator.mediaDevices.getDisplayMedia(constraints) captures a display surface and returns a MediaStream.
  • It always opens a picker. You cannot pre-select a surface or pass a deviceId, and the choice is never remembered.
  • The surface is a monitor, a window, or a browser tab. Read the real choice from track.getSettings().displaySurface.
  • width, height, and frameRate are upper bounds, not targets. cursor, displaySurface, and the newer scoping constraints (selfBrowserSurface, preferCurrentTab, monitorTypeSurfaces, surfaceSwitching, logicalSurface) shape the picker and the frame.
  • The call needs a secure context and a live user gesture. Call it first inside the handler, before any other await.
  • Add the track with addTrack (renegotiates) or swap a camera for it with replaceTrack (no renegotiation). Media stays peer-to-peer; signaling carries only SDP.
  • Set contentHint = 'text' for documents so the encoder drops frames before blurring. Use applyConstraints to throttle a live track.
  • Handle the track ended event to detect a stop from the browser or OS.

Going further

Troubleshooting

  • getDisplayMedia is undefined. The page is not a secure context. Serve over HTTPS or use localhost; file:// will not work.
  • Call rejects with InvalidStateError. Transient activation expired. Call getDisplayMedia directly in the gesture handler, before any other await.
  • Call rejects with NotAllowedError. The user cancelled the picker or the origin is blocked. Treat cancellation as a normal "no," not a crash.
  • Shared text looks blurry on the peer. Set contentHint = 'text' on the video track, and cap frameRate so the encoder spends bandwidth on resolution.
  • Bandwidth spikes on a large monitor. Cap width/height to the remote render size with constraints or applyConstraints; the browser downscales before encoding.
  • A window share goes black. The window was minimized. Window surfaces clip to live bounds and emit no pixels while hidden.
  • The share won't appear on the peer. Adding a track triggers negotiationneeded; make sure you create and exchange a fresh offer. To avoid renegotiation, use replaceTrack on an existing video sender instead.
  • Audio feeds back. Mute the local <video> element and exclude the current tab with selfBrowserSurface: 'exclude'.