Sharing a Tab vs a Window vs the Whole Screen
The three values displaySurface can resolve to — browser, window, monitor — are not three presentations of one capture. They are three different capture engines, with different pixel counts, different failure behaviour when the source is hidden, and wildly different amounts of the user’s desktop on the wire. This guide is part of the Screen Sharing & Content Hints guide, and it settles one decision: which surface to steer the picker toward, and what your code must do differently the moment you learn which one the user actually chose.
Context & Trade-offs
Tab capture never leaves the browser. Chromium taps the frame sink that the renderer’s compositor is already producing for that document, so a captured frame is a GPU texture copy of pixels that existed anyway, with the compositor’s damage rectangles attached. Window and screen capture leave the browser entirely and go through the platform: Windows Graphics Capture or DXGI Desktop Duplication on Windows, ScreenCaptureKit on macOS 12.3+, and xdg-desktop-portal over PipeWire on Wayland. Those paths cost a cross-process composite, and the legacy fallbacks Chromium still uses on older Windows builds and on X11 do a GPU-to-CPU readback per frame, which is where the 10–20% of a core that “screen sharing is slow” tickets usually hides.
Pixel count then multiplies that. A 4K monitor share is four times the macroblocks of a 1080p tab share, and because a desktop is mostly static the encoder’s cost is dominated by the intra frames it has to emit whenever a window moves — which on a full desktop happens constantly. If your fleet is on software VP9, a 3840×2160 capture at scaleResolutionDownBy = 1 will hit qualityLimitationReason: 'cpu' within seconds; whether that is a real ceiling or a missing hardware path is worth checking against Detecting Hardware vs Software Encoding.
tab (browser) |
window (window) |
screen (monitor) |
|
|---|---|---|---|
| Typical capture size | 1280×720 – 1920×1080 | 800×600 – 2560×1440 | 1920×1080 – 3840×2160 |
| Encoder load vs a 1080p tab | 1× | 0.4–1.8× | 1–4× |
| Steady-state bitrate, document content | 300–500 kbps | 300–800 kbps | 500–1200 kbps |
| Keeps painting when occluded | yes | usually | not applicable |
| Keeps painting when minimised | yes | often not | not applicable |
| Pixels exposed beyond intent | none | the app’s other documents | everything on that output |
| Safe default cap | 8 fps at 1× | 8 fps at 1× | 5 fps, 1.5× above 2560 px |
Occlusion and minimisation are where the three diverge hardest. A captured tab keeps compositing for the capture even when its window is buried or minimised, because Chromium deliberately keeps that renderer’s frame production alive for the duration of the share. A captured window depends on the platform: Windows Graphics Capture keeps a backing store for occluded windows, and modern Chromium builds keep delivering frames from a minimised window if the app itself still paints — but many Win32 apps stop painting when minimised, and macOS discards a minimised window’s backing store outright, so the capture freezes on its last painted frame. The Wayland portal is worse: minimising can invalidate the restore token and the stream turns black or ends with no error at all. Crucially, none of these fire an ended event and none set track.muted — readyState stays 'live' while the frame counter flatlines.
Privacy exposure runs the same ordering in reverse. A tab share is scoped to one document; the user can switch tabs, open a second window, and read their email over the top of it without a pixel of that reaching the peer. A window share exposes everything that application renders, which for a browser window means every other tab in it and for an IDE means every open file. A monitor share exposes the output in its entirety, including the OS notification toasts that surface message previews, password-manager autofill popups, and the lock screen.
One quirk cuts the other way. On Windows, child windows — dropdown menus, tooltips, combo-box popups — are separate top-level handles, so a window capture of a native app frequently shows menus opening for the presenter and nothing at all for the viewers. If the shared app is menu-driven, a monitor share is the only one that works, and that is worth saying out loud in your UI rather than fielding the bug report.
Minimal Runnable Implementation
Because displaySurface is only reliably present in getSettings() on Chromium, a portable resolver needs two fallbacks: the MediaStreamTrack.label, which Chromium formats as screen:0:0, window:12345:0, or a web-contents-media-stream:// URL, and a geometry comparison against screen.width. Never let an unknown surface fall through to the tab policy — assuming the cheapest, safest case when you cannot prove it is how a 4K desktop ends up pinned at 1× with maintain-resolution. The reasoning behind pinning resolution at all is set out in degradationPreference: Resolution vs Framerate.
// Per-surface policy: the numbers differ because the capture engines differ.
const SURFACE_POLICY = {
browser: { hint: 'text', maxScale: 1, fps: 8, watchdog: false },
window: { hint: 'text', maxScale: 1, fps: 8, watchdog: true },
monitor: { hint: 'detail', maxScale: 1.5, fps: 5, watchdog: true }
};
function resolveSurface(track) {
const s = track.getSettings();
if (s.displaySurface) return s.displaySurface; // Chromium: authoritative, use it
const label = (track.label || '').toLowerCase(); // Firefox/Safari: no displaySurface
if (label.startsWith('web-contents-media-stream')) return 'browser';
if (label.startsWith('window:')) return 'window';
if (label.startsWith('screen:')) return 'monitor';
// Last resort: a capture as wide as a physical display almost certainly is one.
const wholeDisplay = s.width >= screen.width * (window.devicePixelRatio || 1) * 0.98;
return wholeDisplay ? 'monitor' : 'window'; // never guess 'browser'
}
async function startSurfaceAwareShare(sender) {
const controller = new CaptureController(); // Chromium only; harmless elsewhere
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' }, // hint: open the picker on the tab pane
surfaceSwitching: 'include', // Chrome: swap source without a reprompt
selfBrowserSurface: 'exclude',
controller // must be a top-level member, not video
});
// Only legal in the same task as the resolve, and only for tab/window surfaces.
try { controller.setFocusBehavior('no-focus-change'); } catch { /* screen share or unsupported */ }
const [track] = stream.getVideoTracks();
const apply = async () => {
const surface = resolveSurface(track);
const p = SURFACE_POLICY[surface] ?? SURFACE_POLICY.window; // unknown => treat as window
track.contentHint = p.hint;
const params = sender.getParameters();
if (!params.encodings?.length) params.encodings = [{}]; // Firefox can hand back []
params.degradationPreference = 'maintain-resolution';
// Scale only when the surface is genuinely oversized; text dies below 1:1.
params.encodings[0].scaleResolutionDownBy =
track.getSettings().width > 2560 ? p.maxScale : 1;
params.encodings[0].maxFramerate = p.fps;
await sender.setParameters(params);
return { surface, watchdog: p.watchdog };
};
// surfaceSwitching keeps the same track object but can turn 'window' into 'monitor'
// underneath you: re-run the policy instead of trusting the first resolution.
track.addEventListener('configurationchange', apply); // recent Chromium; no-op elsewhere
await sender.replaceTrack(track); // same SSRC, no offer/answer
return { track, ...(await apply()) };
}
The configurationchange listener is the cheap path, not the portable one — Firefox and Safari never fire it, and neither implements surfaceSwitching at all, so on those engines a source change means a fresh getDisplayMedia() call and a fresh picker. Keep the 1 s settings poll described below as the engine-independent detector. The replaceTrack() swap itself never touches SDP; the cases where it legitimately rejects are covered in Replacing Video Tracks Without Renegotiation.
Reproduction Steps & Debugging Log Patterns
- Start a
windowshare of a native app on Windows, then drag another window fully over it. PollgetStats()at 1 s intervals and watchmedia-source.framesPerSecond— with Windows Graphics Capture it stays at your cap; on the legacy BitBlt path it drops toward 0 and the last frame is retained. - Minimise the shared window. On macOS the capture freezes immediately; on Windows it depends on whether the app keeps painting. In both cases
track.readyStateremains'live'andtrack.mutedremainsfalse, so nothing in the track API tells you. - Restore the window and confirm frames resume within one poll interval. A stream that never resumes is the Wayland restore-token case, and only a fresh
getDisplayMedia()recovers it. - With
surfaceSwitching: 'include', use Chrome’s Change source button to swap a 1280×800 window for a 3840×2160 monitor, and confirm yourconfigurationchangehandler re-ransetParameters()with the new scale factor. - Compare
outbound-rtp.qualityLimitationReasonacross the three surfaces at the same bitrate ceiling;'cpu'appearing only on the monitor share is the pixel-count signal, not a network one.
Arm the watchdog on three consecutive 1 s samples with framesPerSecond === 0 — one zero sample is normal on a genuinely static surface, three in a row on a surface the user believes is live is not. Expected trace for the minimise case:
// t+0s surface=window 1280x800@8 framesPerSecond=7.9 readyState=live
// t+1s surface=window 1280x800@8 framesPerSecond=0 readyState=live <- minimised
// t+2s surface=window 1280x800@8 framesPerSecond=0 readyState=live
// t+3s framesSent delta = 0 for 3 polls -> raise "your window is hidden" banner
// t+9s framesPerSecond=6.4 readyState=live <- restored
The distinguishing test against a network problem is that a frozen capture stops framesEncoded as well as framesSent, while congestion leaves framesEncoded climbing and only starves the wire; separating those two signatures is the whole subject of Interpreting getStats() for Congestion Signals.
Common Implementation Mistakes
- Treating an absent
displaySurfaceas a tab share. Safari reports nothing at all, so a resolver that falls through to the tab policy pins a 5K iMac desktop at 1× and burns a core. Fix: default the unknown branch towindowormonitor, neverbrowser. - Arming the freeze watchdog on
readyStateortrack.muted. Neither changes when a window is minimised or a compositor stops delivering. Fix: watch frame counters, not track state. - Enabling
surfaceSwitchingwithout re-running the encoder policy. The track object, its id, and the SSRC all survive the switch, so nothing in your normal lifecycle code fires — but the geometry can quadruple. Fix: re-derive the policy onconfigurationchangeand on the geometry poll. - Calling
setFocusBehavior()after anawait. The control is only valid in the same task as thegetDisplayMedia()resolve; one interveningawaitand it throwsInvalidStateError, leaving the browser to yank the user into the captured tab mid-sentence. - Advertising “share your screen” for a slide deck. Steering users to a monitor share by default multiplies both the encode cost and the exposure for content that a tab share carries perfectly. Fix: label the primary action for the tab pane and make the full-screen option deliberate.
FAQ
Can I force the user to share only a tab?
Not from the page. displaySurface is advisory in every shipping browser and { exact: 'browser' } is ignored rather than enforced, so the only client-side move is to detect the resolved surface and stop the share yourself with a clear explanation. Real enforcement is administrative: Chrome’s TabCaptureAllowedByOrigins, WindowCaptureAllowedByOrigins, and ScreenCaptureAllowedByOrigins enterprise policies restrict the picker per origin at the browser level.
Does a tab share keep working if the user switches to another tab?
Yes, and this is tab capture’s strongest property. The capture is bound to the document, not to whichever tab is foreground, so the renderer keeps compositing for the share while the user reads something else in the same window. The peer’s picture does not change. A window share of that same browser window would instead broadcast whatever the user switched to.
Why does my monitor share look worse than a tab share at the same bitrate?
Because you are spending the same bits on four times the pixels while the encoder is also fighting a busier picture — cursor movement, clock ticks, and notification animations all defeat static-frame skipping across a whole desktop. Cap maxFramerate at 5 for monitor shares before touching resolution, and only then apply scaleResolutionDownBy above 2560 px of width.
Related: return to Screen Sharing & Content Hints, then read Keeping Shared Text Readable with contentHint for the encoder side of a document share and Capturing System Audio with getDisplayMedia for the audio track that each surface grants differently.