Screen-share lifecycle: start, stop, cleanup

A screen share has a beginning, a middle, and an end you do not control. You decide when to call getDisplayMedia. The user decides everything after that: which surface to share, whether to share at all, and when to stop. The stop can happen anywhere: a button in your UI, the browser's own "Stop sharing" bar, the macOS menu-bar control, closing the shared window, or the tab dying.

This is the problem that makes screen sharing different from a webcam. A getUserMedia camera track lives until you stop it. A getDisplayMedia track lives until the user revokes it through an interface you never built and cannot style. If you treat the track as something you own, your UI drifts out of sync with reality: a "Stop sharing" button that does nothing, a remote peer still staring at a frozen frame, a sender pinned to a dead track.

The fix is to treat the screen-share track as an event source. You start it, then you listen. The track tells you when it ends. Your job is to wire that signal to cleanup: stop the track, detach it from the sender, update state, and coordinate the renegotiation that follows removing media from a connection.

This guide walks the whole arc. It assumes you can already capture a surface; if not, read capturing the screen first. It assumes you understand how a track attaches to a connection through a sender; if not, read tracks and streams. Adding or removing a track changes the set of media on the connection, which forces a renegotiation. That machinery lives in the connection state machine.

The shape of the lifecycle

Five states, and the transitions between them.

State Meaning How you got here
idle No share. Camera or nothing on the sender. Initial state, or after cleanup.
requesting getDisplayMedia promise pending; picker open. You called getDisplayMedia.
sharing A live screen track is attached and flowing. Picker resolved; track attached; renegotiation done.
stopping Tearing down: stopping track, detaching, renegotiating. User or code initiated stop.
ended Share gone. Often a transient step back to idle. Cleanup finished.

The transition that catches people out is requesting → idle. The user opens the picker and clicks Cancel. The promise rejects. If you set state to sharing before the promise resolves, you now show a sharing UI with no track behind it.

idle requesting sharing stopping ended getDisplayMedia() picker OK + attach + renegotiate track 'ended' OR stop() stop track, detach, renegotiate cancel: NotAllowedError reset UI re-share

The events that drive these transitions:

Event Source Fires when
promise resolve getDisplayMedia User picks a surface and confirms.
promise reject getDisplayMedia User cancels, or permission policy blocks.
ended on the track The track itself User stops via any UI outside yours, or the source dies.
negotiationneeded RTCPeerConnection The sender's track set changed (attach or detach).
connectionstatechange RTCPeerConnection Transport state moved (connected, failed, closed).

Two of these come from outside your code path: the promise rejection and the track ended event. They are the load-bearing parts of the lifecycle. Get them right and the rest follows.

Starting: the picker and its two outcomes

getDisplayMedia must run inside a user gesture. A click handler, a keypress: something the browser attributes to the user. Call it from a timer or on page load and it rejects before any picker appears.

The call returns a promise. It has exactly two outcomes that matter.

async function startShare() {
  state = 'requesting';
  let stream;
  try {
    stream = await navigator.mediaDevices.getDisplayMedia({
      video: { displaySurface: 'monitor' },
      audio: false,
    });
  } catch (err) {
    // Outcome two: the user cancelled, or policy blocked the call.
    state = 'idle';
    handleStartFailure(err);
    return;
  }

  // Outcome one: the user picked a surface and confirmed.
  const [track] = stream.getVideoTracks();
  await attachShare(track);   // covered below
}

Outcome one: the promise resolves with a MediaStream. Pull the video track out of it. That track is what you attach to the connection and what you listen to for the end of the share.

Outcome two: the promise rejects. The common case is the user clicking Cancel in the picker. The browser reports this as a DOMException with name === 'NotAllowedError', the same name a hard permission denial uses. You cannot reliably tell "user cancelled" apart from "permission blocked" by the error name alone; both surface as NotAllowedError. Treat both as "no share happened" and return to idle.

function handleStartFailure(err) {
  switch (err.name) {
    case 'NotAllowedError':
      // Cancelled the picker, or blocked by permissions-policy. No share.
      // Do not show an error toast for a deliberate cancel: stay quiet.
      break;
    case 'NotFoundError':
      // No shareable surface available.
      notify('No screen available to share.');
      break;
    case 'NotReadableError':
      // OS-level capture error (another app holds the source, hardware fault).
      notify('Could not start the screen capture.');
      break;
    case 'AbortError':
      // The session ended before it began (rare).
      break;
    default:
      notify('Screen share failed.');
  }
}
Set sharing state after the promise, not before

A cancelled picker rejects with NotAllowedError. If you flip your UI to "sharing" on the click, before await resolves, a cancel leaves you in a sharing state with no track. Set requesting on the click and sharing only after attach succeeds.

The picker is modal and out of your control. While it is open the promise stays pending: there is no timeout you can set, no way to cancel it from code, and no progress event. The user may sit on the picker for a minute. Your requesting state has to tolerate that. Disable the share button while the promise is pending so a second click cannot open a second picker, and re-enable it in both the resolve and reject paths.

A share can also produce more than one track. Ask for audio: true and a resolved stream may carry a system-audio track alongside the video track. That audio track has its own independent lifecycle. It fires its own ended, and a user can stop video while audio keeps flowing in some browsers. If you capture audio, wire ended on both tracks and stop both in cleanup. The video track is the one most code keys the UI off, but the audio track is a second source you now own and must release.

const tracks = stream.getTracks();          // may be [video] or [video, audio]
tracks.forEach(wireEnded);                  // listen on every track you got

Attaching: the track joins the connection

A captured track does nothing on its own. It has to reach the peer. You attach it through an RTCRtpSender. Two paths exist, and the choice shapes how you stop later.

Path one: addTrack. Use this when no video sender exists yet. It creates a sender, which changes the connection's media set, which fires negotiationneeded.

async function attachShare(track) {
  shareTrack = track;
  shareSender = pc.addTrack(track, new MediaStream([track]));
  wireEnded(track);          // listen for the user stopping, covered next
  // addTrack triggers negotiationneeded; the handler renegotiates.
  state = 'sharing';
  updateUI();
}

Path two: replaceTrack. Use this when a sender already carries video, typically a camera you want to swap for the screen. replaceTrack swaps the source on an existing sender. It does not change the media set, so it does not renegotiate. This is the cheaper path, and it is why call apps that toggle camera↔screen prefer it.

async function attachShareBySwap(track) {
  shareTrack = track;
  await videoSender.replaceTrack(track);   // no renegotiation
  wireEnded(track);
  state = 'sharing';
  updateUI();
}

Which path you took decides how you stop. If you added a sender, you remove it and renegotiate. If you swapped a track, you swap back (or to null) and you do not renegotiate. The article keeps both straight from here on. For the full sender model and the addTrack/replaceTrack/addTransceiver differences, see tracks and streams.

Detecting the stop you did not initiate

This is the center of the whole guide.

The user can stop the share through controls you never built:

  • The browser's "Stop sharing" notification bar at the bottom or top of the screen.
  • The macOS menu-bar screen-recording indicator.
  • The system tray control on Windows.
  • Closing the window or tab being shared.
  • The browser revoking capture for its own reasons.

Every one of those ends the underlying track. The track signals this by firing an ended event and moving its readyState to 'ended'. This event is the canonical, browser-agnostic way to know the user stopped the share. You do not get a callback from the picker. You do not get a message. You get ended on the track.

function wireEnded(track) {
  // Fires when the source stops for any reason OUTSIDE your stop() path:
  // the browser's Stop bar, the OS control, closing the window, hardware loss.
  track.addEventListener('ended', onShareEnded, { once: true });
}

async function onShareEnded() {
  // The track is already 'ended': its readyState is 'ended', source is gone.
  // You do NOT call track.stop() here; it has already stopped itself.
  await cleanupShare({ trackAlreadyEnded: true });
}

Two properties of the ended event matter.

First, it is the only reliable cross-browser signal. There is no getDisplayMedia-level "share ended" callback. The track's ended event is it. Listen on the video track you pulled out of the stream, not on the stream, not on the sender.

Second, it fires only when the source stops on its own. It does not fire when you call track.stop() yourself. That asymmetry is the next section, and it is the single most common bug in screen-share code.

The legacy track.onended = fn property form works too and is what older code uses. Prefer addEventListener so you can remove the listener cleanly during a programmatic stop and avoid a stale closure firing later. Either way, register the listener the moment you receive the track, before you do anything async with it. A user can stop a share within the same frame they started it, fast enough that a listener wired a tick too late misses the event entirely.

There is no separate "did the user stop or did the source die" signal. A closed window, a disconnected monitor, a browser that revokes capture under memory pressure: all of them surface as the same ended event. Treat every ended as "the share is over, clean up". If you need to know why, the readyState is 'ended' and that is all the platform tells you; do not branch on a reason the API does not provide.

USER STOPS CODE STOPS Browser Stop bar / OS control Track fires 'ended' onShareEnded handler Your stopShare() track.stop() No 'ended' event call cleanup yourself cleanupShare()

Stopping programmatically: the silent path

Your own "Stop sharing" button calls track.stop(). Calling stop() on a track ends it: readyState becomes 'ended', the camera/screen capture indicator goes away, and the bytes stop. But stop() does not fire the ended event. The spec is explicit: ended fires when a track ends "for reasons other than a call to stop()".

So if your only cleanup lives inside the ended handler, your own stop button will release the OS capture but leave your UI claiming "sharing", your sender holding a dead track, and your remote peer none the wiser.

The fix is to route both stop paths through one cleanup function, and to call that function yourself on the programmatic path.

async function stopShare() {
  // The user clicked YOUR stop button.
  if (state !== 'sharing') return;
  await cleanupShare({ trackAlreadyEnded: false });
}

async function cleanupShare({ trackAlreadyEnded }) {
  state = 'stopping';

  // 1. Stop the track. Skip if the source already ended itself.
  if (!trackAlreadyEnded && shareTrack) {
    shareTrack.removeEventListener('ended', onShareEnded);
    shareTrack.stop();        // releases the OS capture; fires NO 'ended'
  }

  // 2. Detach from the sender: the path depends on how you attached.
  await detachShare();

  // 3. Update local state and UI.
  shareTrack = null;
  state = 'idle';
  updateUI();

  // 4. Tell the remote peer the view is gone (see "Coordinating the stop").
  signalShareStopped();
}

The trackAlreadyEnded flag is doing real work. On the ended path the track has already stopped itself, so calling stop() again is a no-op but removing the now-fired listener is tidy. On the button path the track is still live, so you stop it and you remove the listener before it can fire. Both paths land in the same cleanup, so the UI ends up in one consistent state regardless of who initiated the stop.

The rule in one line

The ended event fires for stops you did not cause. track.stop() is a stop you did cause, so it does not fire ended. Centralise cleanup, then call it yourself on the programmatic path and let the event call it on the external path.

Detaching and the renegotiation that follows

Stopping the track stops the capture. It does not change the connection. The sender still exists, still points at a now-dead track, and the connection's media description still lists it. To actually remove the media you detach it from the sender, and the detach choice mirrors the attach choice.

If you added a dedicated sender with addTrack, remove it with removeTrack. That changes the connection's media set and fires negotiationneeded.

async function detachShare() {
  if (shareSender) {
    pc.removeTrack(shareSender);   // changes media set → negotiationneeded
    shareSender = null;
  }
}

If you swapped the screen onto an existing camera sender with replaceTrack, swap back. Either restore the camera track, or pass null to leave the sender live but sending nothing. replaceTrack(null) keeps the transceiver in place and does not renegotiate: the cheap path again.

async function detachShare() {
  // Camera↔screen swap case: go back to the camera, or to nothing.
  await videoSender.replaceTrack(cameraTrack ?? null);
}

removeTrack triggers a renegotiation; replaceTrack does not. That is the whole difference, and it is why the attach path you chose dictates the detach path.

When a renegotiation is triggered, the connection has to exchange a fresh offer/answer so both peers agree on the new media set. The browser fires negotiationneeded; you build an offer, send it over your signaling channel, apply the answer. None of the SDP carries media; it describes the media. The frames always stay peer-to-peer over the connection; signaling moves only the SDP. The mechanics, including the glare problem when both sides renegotiate at once, are the state machine's job.

pc.addEventListener('negotiationneeded', async () => {
  try {
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    signaling.send({ type: 'offer', sdp: pc.localDescription });
  } catch (err) {
    console.error('renegotiation failed', err);
  }
});

A subtlety: removeTrack does not free the transceiver. The m-line stays in the SDP, marked inactive (recvonly or inactive). The slot is reused if you share again later. This is why a second share through the same path costs another renegotiation but no new transceiver: the existing one is repurposed.

Coordinating the stop with the remote peer

The remote peer renders your screen into a <video> element. When your share ends, that element keeps showing the last frame unless something tells it otherwise. Two mechanisms can clear it, and they are not equivalent.

Mechanism one: the track ends on the remote side. When you removeTrack and renegotiate, or when the transport tears down, the remote peer's receiver eventually sees its track go to 'ended' or 'muted'. The remote can listen for that and clear the video. This works, but it is late: it waits on the renegotiation round-trip, and a replaceTrack(null) swap does not end the remote track at all; it just goes silent, leaving a frozen frame.

Mechanism two: an explicit control message. The moment you stop, send a small message over your data channel telling the peer the share is gone. The peer clears the video immediately, before any media-level signal arrives. This is the reliable path, and it is the one to use.

function signalShareStopped() {
  // Control message over the data channel: clears the remote view at once.
  // Use a 'c'-prefixed control key so it never collides with in-game traffic.
  dataChannel.send(JSON.stringify({ c: 'share', state: 'stopped' }));
}

On the receiving side:

function onControlMessage(msg) {
  if (msg.c === 'share' && msg.state === 'stopped') {
    remoteVideo.srcObject = null;     // clear the frozen frame now
    showPlaceholder('Sharing ended');
  }
}

The control message and the track-level signal are belt and braces. The message updates the UI promptly. The track ending is the ground truth that survives a lost message. Send both; rely on the message for timing.

Note the c: key. When the same data channel carries both control and application traffic, prefix control messages with c: so they never collide with your in-game or in-app message types. This is the convention the hub/ layer uses to multiplex control over a shared channel.

Re-sharing after a stop

Once a share ends, the track is gone for good. A track in readyState === 'ended' is dead: you cannot restart it, re-enable it, or reattach it. Re-sharing means starting the whole acquisition over.

async function reshare() {
  if (state !== 'idle') return;        // only from a clean stop
  await startShare();                  // fresh getDisplayMedia + picker
}

startShare runs the same arc: a user gesture, a fresh getDisplayMedia call, a new picker, a brand-new track. Attach it the same way you did the first time (addTrack if no sender exists, replaceTrack onto the existing one if it does) and the same renegotiation rule applies. If the transceiver from the first share is still in place, the second addTrack may reuse it; either way the media set changed, so expect negotiationneeded to fire.

Re-acquiring shows the picker again. There is no way to re-share the previous surface silently: the user re-confirms what to share every time. That is a deliberate privacy property of getDisplayMedia, not a limitation to work around.

Switching surfaces mid-session

A user mid-share may want to switch from one window to another without stopping and restarting. Newer browsers support surfaceSwitching for this. Pass it in the constraints to get a "Share this tab instead" control inside the browser's own sharing UI.

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  surfaceSwitching: 'include',   // browser shows a switch-surface control
});

The behaviour to understand: when the user switches surface through that control, the track does not end. The same MediaStreamTrack keeps flowing; only its source changes underneath. So ended does not fire, the sender keeps its track, and no renegotiation happens. Your code does nothing: the new surface streams over the existing track automatically.

The track's getSettings() may report new dimensions after a switch, and the track fires a configurationchange event in browsers that support it. If your UI shows the shared dimensions or you size the remote video to match, listen for that.

shareTrack.addEventListener('configurationchange', () => {
  const { width, height } = shareTrack.getSettings();
  // Surface changed; the track is the same one, still attached.
  updateShareDimensions(width, height);
});

surfaceSwitching is a convenience layered on a single persistent track. It does not interact with the start/stop lifecycle: there is no new picker promise, no new track, no renegotiation. The only lifecycle event a surface switch produces is configurationchange, and only when the geometry changes.

Tying connection state to share state

The share lifecycle and the connection lifecycle are separate machines that touch at the edges. Keep them distinct, then react to the connection where it forces your hand.

connectionstatechange reports the transport, not the share. But some transport transitions invalidate an active share:

connectionState What it means for a live share
connecting Renegotiation in flight; the share may not be flowing yet.
connected Media path is up; the share streams.
disconnected Transient. Often recovers. Hold the share; do not tear down yet.
failed Transport is dead. The share cannot flow. Clean up.
closed Connection gone. Stop the track, reset to idle.
pc.addEventListener('connectionstatechange', async () => {
  switch (pc.connectionState) {
    case 'failed':
    case 'closed':
      // Transport is gone; no point keeping the OS capture running.
      if (state === 'sharing') {
        await cleanupShare({ trackAlreadyEnded: false });
      }
      break;
    case 'disconnected':
      // Transient. Wait, it may recover without action.
      break;
  }
});

The asymmetry to hold in mind: a share ending does not end the connection; you stop one track and keep talking. A connection ending does end the share: there is nowhere for the frames to go, so release the OS capture and reset. Wire failed/closed to your cleanup; leave disconnected alone and let ICE try to recover. The full meaning of each transport state is in the state machine guide.

The whole lifecycle in one module

The pieces fit into a small self-contained controller. It owns the share state, exposes start, stop, and reshare, routes every stop through one cleanup function, and signals the peer. The addTrack/removeTrack path is shown; the swap variant is a two-line change noted inline.

function shareController(pc, dataChannel) {
  let state = 'idle';
  let track = null;
  let sender = null;

  function onEnded() {
    // External stop: Stop bar, OS control, closed window. Track already dead.
    cleanup({ trackAlreadyEnded: true });
  }

  async function start() {
    if (state !== 'idle') return;
    state = 'requesting';
    let stream;
    try {
      stream = await navigator.mediaDevices.getDisplayMedia({
        video: { displaySurface: 'monitor' },
        surfaceSwitching: 'include',
      });
    } catch (err) {
      state = 'idle';
      if (err.name !== 'NotAllowedError') reportShareError(err); // cancel = quiet
      return;
    }
    track = stream.getVideoTracks()[0];
    track.addEventListener('ended', onEnded, { once: true });
    sender = pc.addTrack(track, stream);   // → negotiationneeded fires
    state = 'sharing';
    render();
  }

  // Your own Stop button. track.stop() fires NO 'ended', so cleanup runs here.
  function stop() {
    if (state !== 'sharing') return;
    cleanup({ trackAlreadyEnded: false });
  }

  function cleanup({ trackAlreadyEnded }) {
    state = 'stopping';
    if (track && !trackAlreadyEnded) {
      track.removeEventListener('ended', onEnded);
      track.stop();
    }
    if (sender) {
      pc.removeTrack(sender);              // → negotiationneeded fires
      sender = null;
    }
    track = null;
    dataChannel.send(JSON.stringify({ c: 'share', state: 'stopped' }));
    state = 'idle';
    render();
  }

  async function reshare() {
    if (state !== 'idle') return;
    await start();                         // fresh picker, fresh track
  }

  return { start, stop, reshare, get state() { return state; } };
}

The renegotiation handler and the connectionstatechange handler sit on the connection, not in the controller: they belong to the connection's lifecycle and serve every track, not just the share. The controller only has to make the media-set changes; the connection's own handlers carry the renegotiation through.

Recap

The screen-share lifecycle has two signals you do not control, and everything turns on handling them.

  • Start with getDisplayMedia inside a user gesture. The promise resolves with a stream, or rejects. A cancelled picker rejects with NotAllowedError, indistinguishable from a hard block, and not an error to shout about. Set sharing state only after the promise resolves and the track attaches.
  • The ended event on the video track is the canonical way to learn the user stopped the share through the browser's Stop bar, the OS control, or by closing the window. It fires only for stops you did not initiate.
  • track.stop() does not fire ended. Your own stop button must call cleanup directly. Route both paths (the event and the button) through one cleanup function.
  • Detach mirrors attach. addTrackremoveTrack and renegotiate. replaceTrack(track)replaceTrack(null) and no renegotiation. The path you chose to attach decides the path to stop.
  • Tell the remote peer explicitly. A control message clears the remote view at once; the track-level signal is the slower ground truth. Send both.
  • Re-sharing is a fresh acquisition. An ended track is dead. Call getDisplayMedia again; the picker reappears every time.
  • surfaceSwitching changes the source under a persistent track: no ended, no new track, no renegotiation, only configurationchange.
  • Connection state and share state are separate. A share ending leaves the connection up; a failed/closed connection ends the share.

Going further

Troubleshooting

Symptom Cause Fix
Your "Stop" button releases capture but the UI still says sharing. Cleanup lives only in the ended handler; track.stop() does not fire ended. Route the button through cleanupShare directly.
UI shows "sharing" but no video is flowing. State set to sharing on click, before the picker promise resolved or was cancelled. Set sharing only after await getDisplayMedia resolves and attach succeeds.
An error toast appears every time the user cancels the picker. Treating the NotAllowedError rejection as a failure to report. Cancel and block both surface as NotAllowedError; stay silent on cancel.
Remote peer keeps showing a frozen last frame after you stop. Relying on the track ending; replaceTrack(null) never ends the remote track. Send an explicit control message and clear srcObject on receipt.
Stopping the share kills the whole call. Removing the only video sender and treating renegotiation failure as fatal. Use replaceTrack(null) to keep the transceiver, or handle renegotiation cleanly.
Re-sharing throws or sends a black frame. Reusing the old ended track. Call getDisplayMedia again for a new track; an ended track cannot restart.
Surface switch triggers your stop/cleanup logic. Assuming a switch ends the track. surfaceSwitching keeps the same track; listen for configurationchange, not ended.
getDisplayMedia rejects immediately with no picker. Called outside a user gesture, or blocked by permissions-policy. Call it from a click handler; check the document's display-capture policy.