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.

Capture path and per-frame cost by display surface Four rows compare the three display surfaces. Tab capture reads the renderer compositor in process with a GPU texture copy at baseline load. Window capture reads an OS backing store through Windows Graphics Capture, ScreenCaptureKit or the desktop portal, costing a cross-process composite. Monitor capture duplicates the display framebuffer at up to four times the pixel count of a 1080p tab. Where the pixels come from, and what each hop costs Browser tab Application window Whole screen Source pixels Capture API Frame path Cost at 30 fps renderer compositor viewport only, CSS pixels OS window backing store client area, no chrome display framebuffer every pixel on that output in-process frame sink damage rects available WGC / ScreenCaptureKit or xdg-desktop-portal DXGI duplication / SCK one surface per monitor GPU texture copy no CPU readback cross-process composite readback on legacy paths dirty-rect blit always full resolution 1920x1080 = 2.1 Mpx baseline encoder load tracks the window size +10-20% of one core 3840x2160 = 8.3 Mpx 4x the tab: cap or scale Only the tab path stays inside the browser process — which is why it is both the cheapest and the least exposed.
Three surfaces, three capture engines: the cost difference is architectural, not a matter of resolution.

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 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.mutedreadyState 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.

Privacy blast radius of each display surface Concentric boxes place the tab viewport inside the application window inside the whole monitor. Each surrounding band lists what becomes visible to remote peers at that scope, and a side panel itemises the specific leaks: other tabs, other documents in the same app, notification toasts, password manager popups and the lock screen. What the peer receives, by surface scope monitor toasts · taskbar · every other app window browser chrome · tab strip · URL bar tab one document's viewport nothing else, ever modal dialogs · screensaver · lock screen Leak inventory tab — one document, even after the user switches tabs window — every file the app shows, minus child menus on Windows monitor — IM previews, autofill popups, anything that pops up second monitor — its own surface, not shared unless picked too cursor: rendered unless "never"
The scope you cannot audit is the scope you should not default to: pre-select the tab pane wherever the content allows it.

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.

Branching on the resolved surface A root node reads getSettings displaySurface and branches into four policies: browser tab with a text hint at one times scale and no freeze watchdog, window with the same encoder settings plus a watchdog, monitor with a detail hint and downscaling above 2560 pixels, and an undefined branch that sniffs the track label before defaulting to window. Branch on what resolved, never on what you requested videoTrack.getSettings() .displaySurface "browser" "window" "monitor" undefined hint: "text" scale 1x, 8 fps cap survives minimise scope: one viewport no-focus-change ok hint: "text" scale 1x, 8 fps cap freeze watchdog ON scope: whole app menus may vanish hint: "detail" >2560px: scale 1.5x 5 fps cap scope: everything warn in your own UI Safari, older Firefox sniff track.label else match screen.width else assume window never assume tab Every branch still converges on one "ended" handler, one 1 s stats poll, and one re-run after a surface switch.
Four branches, one shared tail: the policy differs, the lifecycle handling does not.

Reproduction Steps & Debugging Log Patterns

  1. Start a window share of a native app on Windows, then drag another window fully over it. Poll getStats() at 1 s intervals and watch media-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.
  2. Minimise the shared window. On macOS the capture freezes immediately; on Windows it depends on whether the app keeps painting. In both cases track.readyState remains 'live' and track.muted remains false, so nothing in the track API tells you.
  3. 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.
  4. With surfaceSwitching: 'include', use Chrome’s Change source button to swap a 1280×800 window for a 3840×2160 monitor, and confirm your configurationchange handler re-ran setParameters() with the new scale factor.
  5. Compare outbound-rtp.qualityLimitationReason across the three surfaces at the same bitrate ceiling; 'cpu' appearing only on the monitor share is the pixel-count signal, not a network one.
Frame delivery through occlusion, minimise and restore Three lanes over a ten second timeline with events at two, five and eight seconds. Tab capture delivers frames throughout. Window capture via Windows Graphics Capture or ScreenCaptureKit is unaffected by occlusion but freezes on minimise until restore. Window capture on the legacy or portal path degrades from the moment of occlusion. Frame delivery while the surface is hidden occluded minimised restored tab capture window: WGC window: legacy renderer keeps compositing for the capture — no gap occlusion is irrelevant fps 0, last frame held recovers capturing stale pixels, then black once the restore token is dropped may never return 0s 2s 5s 8s 10s Watchdog rule: three consecutive 1 s samples with media-source.framesPerSecond === 0 while readyState is "live" and track.muted is false — no event fires for any of this.
Only the tab lane is unconditional; every window lane needs a frame-counter watchdog because the track API stays silent.

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

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.