Handling Device Hotplug & Permission Changes

A user joins a call on a laptop webcam, then plugs in a USB headset mid-meeting, then a browser update silently revokes camera access on the next reload. Each of these is a state change your capture code must observe and react to — not a static snapshot taken once at join time. This guide is part of the Media Constraints & Device Enumeration guide, and it solves one problem: keeping device selection, permission state, and live tracks correct as hardware appears and disappears and permissions shift underneath you. The two primitives are the devicechange event on navigator.mediaDevices and the change event on a PermissionStatus from the Permissions API.

Context & Trade-offs

The failure most apps ship is treating enumerateDevices() as a one-time call. Enumerate once at startup, cache the list, and the moment a user unplugs the active webcam your track ends, the <video> element freezes, and nothing re-binds because nothing was listening. The devicechange event exists precisely to invalidate that cache: it fires whenever the set of available media devices changes, on plug, unplug, or default-device reassignment by the OS.

Permission state is the orthogonal axis. The Permissions API exposes camera and microphone as queryable, observable states (granted, denied, prompt). Observing them lets you distinguish “no devices” from “devices exist but access was revoked” — two situations that demand opposite UI. Polling getUserMedia() to probe permission is wasteful and prompts the user; a PermissionStatus.onchange handler is event-driven and silent. The trade-off is coverage: Firefox historically did not support querying camera/microphone permissions, so the Permissions branch must degrade gracefully where navigator.permissions.query throws or is absent.

Because the two axes are independent, the correct UI is a function of both, and the six combinations do not collapse into “works” and “broken” — a denied permission with hardware attached must never render the same message as a granted permission with nothing plugged in.

Permission state crossed with device availability A three-by-two matrix. Rows are the Permissions API states granted, prompt and denied. Columns are whether a videoinput device is present or absent. Each cell names the UI state the application must render, showing that a denied permission outranks a missing device. Correct UI = permission state × device availability never render "no camera found" when the real cause is a revoked grant videoinput present videoinput absent granted labels populated Capture and render normally picker shows real device names "No camera found" keep devicechange listener armed prompt labels empty Blank-labelled placeholders show "Allow camera" call to action Indistinguishable from above ask first, re-enumerate after denied revoked mid-session "Camera access blocked" stop live tracks, link to settings Blocked outranks missing still say blocked, not "none"
The two signals are orthogonal: only their intersection determines the message the user should see.

The two events interact in ways worth mapping explicitly. A label-bearing enumerateDevices() result is itself a permission signal: before access is granted, device labels are empty strings and deviceId values are placeholders, so a devicechange that arrives while permission is still prompt cannot tell you which device was added — only that the count changed. Granting permission therefore often produces a follow-on devicechange (or at least newly populated labels on the next enumeration) as the browser unlocks metadata. Treat permission grant as a trigger to re-enumerate and rebuild your picker with real labels, and treat permission revocation as a trigger to stop live tracks and surface a “camera access blocked” state rather than a misleading “no camera found” one. Building something usable out of those anonymous pre-grant entries is a problem in its own right, worked through in Enumerating Devices Before Permission Is Granted.

Two more constraints shape the design. First, deviceId is not a durable identifier — it is stable per origin within a session but can rotate across browser updates, OS audio-stack resets, and (notably in Safari, where the WebKit-specific behaviour is catalogued in Debugging WebRTC on Safari and iOS WKWebView) plain page reloads, so any persisted selection must be re-validated, never trusted blindly. Second, when the active device vanishes you want graceful track replacement, not stream teardown: swap the dead track for one from a surviving device so the peer connection and renderer survive intact.

There is also a quantification worth internalising: replacing a track via replaceTrack() keeps the connection in its connected state and costs only the time to spin up the new capture plus one keyframe — typically a few hundred milliseconds of frozen remote video. Tearing the stream down and re-adding a track, by contrast, forces a full SDP offer/answer round-trip, which on a real signalling channel adds the negotiation latency on top (commonly another 200–800 ms even on a healthy WebSocket) and risks the remote renderer resetting entirely. The replacement path is strictly cheaper, which is why every hotplug handler should reach for it first.

Minimal Runnable Implementation

// Single source of truth for the user's chosen devices and the live stream
const state = { videoId: localStorage.getItem('camId'), stream: null };

// 1) React to hardware hotplug — never cache enumerateDevices() permanently
navigator.mediaDevices.addEventListener('devicechange', async () => {
  const devices = await navigator.mediaDevices.enumerateDevices();
  const cams = devices.filter((d) => d.kind === 'videoinput');

  // Is our currently-selected camera still present?
  const stillPresent = cams.some((c) => c.deviceId === state.videoId);
  if (!stillPresent && state.stream) {
    console.warn('Active camera removed — replacing track');
    await replaceVideoFrom(cams[0]?.deviceId);  // fall back to first survivor
  }
  rebuildDevicePicker(cams);                     // refresh the UI list
});

// 2) Observe permission changes via the Permissions API (feature-detected)
async function watchPermissions() {
  if (!navigator.permissions?.query) return;     // Firefox / older WebKit
  try {
    const cam = await navigator.permissions.query({ name: 'camera' });
    handlePermission(cam.state);                  // granted | denied | prompt
    cam.onchange = () => handlePermission(cam.state);  // revoked or granted later
  } catch {
    // 'camera' not a queryable permission name here — skip silently
  }
}

// 3) Graceful track replacement on unplug — keep the stream & PC alive
async function replaceVideoFrom(deviceId) {
  if (!deviceId) { handlePermission('no-device'); return; }
  const fresh = await navigator.mediaDevices.getUserMedia({
    video: { deviceId: { ideal: deviceId } }     // ideal, not exact — survives ID churn
  });
  const newTrack = fresh.getVideoTracks()[0];
  state.videoId = newTrack.getSettings().deviceId;
  localStorage.setItem('camId', state.videoId);  // persist the re-validated id

  // Swap into the existing sender WITHOUT renegotiation; stop the dead track
  const sender = window.pc?.getSenders().find((s) => s.track?.kind === 'video');
  if (sender) await sender.replaceTrack(newTrack);
  state.stream?.getVideoTracks().forEach((t) => t.stop());
  state.stream = fresh;
}

The replaceTrack() call is what keeps this cheap: it substitutes the media source on an existing RTCRtpSender without touching the SDP, so no offer/answer round-trip is needed — the mechanics are detailed in Replacing Video Tracks Without Renegotiation. Requesting the replacement with deviceId: { ideal } rather than { exact } means a rotated ID degrades to “closest available camera” instead of throwing OverconstrainedError, and the full decision procedure for which constraints deserve exact is set out in exact vs ideal Constraints Without OverconstrainedError.

End to end, one physical unplug walks through four participants — the OS device layer, the browser media stack, your handler, and the sender feeding the remote peer — and the SDP is never touched at any point.

Unplug to replaceTrack sequence Four lifelines: USB camera, browser media stack, devicechange handler and RTCRtpSender feeding the remote peer. The unplug produces a track ended event and a payload-free devicechange, the handler debounces then re-enumerates, acquires a fresh stream and calls replaceTrack, all without any SDP offer or answer. USB camera OS device layer Browser media stack mediaDevices Your handler reconcile state RTCRtpSender to remote peer cable pulled track fires 'ended' devicechange (no payload) debounce 150–250 ms enumerateDevices() getUserMedia ideal deviceId fresh track from survivor replaceTrack(newTrack) no offer/answer — connectionState stays "connected"
One unplug, two independent notifications, and a single reconciliation pass that never renegotiates.

Two details make this handler production-safe. First, debounce the devicechange listener: a single physical unplug can emit two or three events as the OS reassigns defaults, so wrap the body in a short trailing debounce (around 150–250 ms) to coalesce the burst into one reconciliation pass and avoid acquiring three replacement streams in a row. Second, the listener body re-runs enumerateDevices() every time because the event carries no payload — there is no event.device, no diff, nothing but the signal that something changed. You learn what changed only by comparing a fresh enumeration against the list you held before, which is why a single cached state object that you reconcile against is cleaner than scattering device lookups across the codebase.

Reproduction Steps & Debugging Log Patterns

  1. Start a capture with getUserMedia({ video: true }) and render it. Confirm the active deviceId via track.getSettings().deviceId.
  2. Physically unplug the active USB camera (or, on Chrome, use chrome://device-log to confirm the removal). Watch devicechange fire and the replacement run.
  3. Listen for the dead track’s ended event — it fires when the underlying device disappears, independent of devicechange.
  4. Revoke camera permission via the site settings (the camera icon in the address bar), reload, and confirm the PermissionStatus change handler reports denied.
  5. Re-grant and confirm the handler flips back to granted without a manual page refresh.

Expected healthy hotplug log:

// track 'ended'        deviceId=4f2a... reason=device-removed
// devicechange fired   videoinput count: 2 -> 1
// Active camera removed — replacing track
// replaceTrack ok      new deviceId=9b7c... (no renegotiation)
// permission: granted  (unchanged)

Those five lines are not five instants — they are a ~600 ms window in which the remote peer sees a still frame, and the debounce deliberately spends part of it waiting for the event burst to settle.

Millisecond timeline of one unplug A time axis from zero to nine hundred milliseconds. The track ended event fires at zero, three devicechange events arrive within one hundred and forty milliseconds, a two hundred millisecond debounce coalesces them, a single reconciliation pass runs at about three hundred and forty milliseconds, and replaceTrack completes near six hundred milliseconds, bounding the remote freeze. One unplug on the wire: what the healthy log looks like in time three notifications, one reconciliation, one bounded freeze 0 ms 150 300 450 600 750 900 ms track 'ended' devicechange x3, defaults reassigned debounce 200 ms reconcile pass enumerate + getUserMedia replaceTrack ok keyframe, freeze ends remote video frozen on last frame
The debounce costs 200 ms of the freeze budget and buys idempotence: one reconciliation instead of three.

A broken session instead shows the ended event with no follow-up replaceTrack ok line, the <video> element frozen on its last frame, and pc.getStats() reporting the outbound-rtp video stream with framesEncoded flatlined. If the Permissions branch is silently dead, you will see getUserMedia rejecting with NotAllowedError while no permission: denied log ever appears — the signature of a browser where permissions.query({ name: 'camera' }) threw and was swallowed.

Common Implementation Mistakes

FAQ

Why does devicechange fire twice on a single unplug?

The OS often reports the change in stages (device removed, then default reassigned), and some browsers coalesce inconsistently. Make your handler idempotent — re-enumerate and reconcile against current state rather than assuming one event equals one delta.

How do I handle browsers that do not support querying camera permissions?

Feature-detect navigator.permissions?.query and wrap the call in try/catch. Where it is unavailable (older Firefox/WebKit), fall back to inferring state from getUserMedia() outcomes — NotAllowedError implies denied, success implies granted — and skip the live onchange observation.

Will replacing a track on unplug interrupt the remote peer’s video?

Briefly. replaceTrack() needs no renegotiation, so the connection stays up, but the remote sees a short freeze while the new source spins up and a keyframe is produced. It is far less disruptive than removing and re-adding the track, which would force a full offer/answer cycle.

Related: return to Media Constraints & Device Enumeration, and read Audio/Video Track Management and Replacing Video Tracks Without Renegotiation.