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.
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.
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) 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.
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
- Start a capture with
getUserMedia({ video: true })and render it. Confirm the activedeviceIdviatrack.getSettings().deviceId. - Physically unplug the active USB camera (or, on Chrome, use
chrome://device-logto confirm the removal). Watchdevicechangefire and the replacement run. - Listen for the dead track’s
endedevent — it fires when the underlying device disappears, independent ofdevicechange. - Revoke camera permission via the site settings (the camera icon in the address bar), reload, and confirm the
PermissionStatuschange handler reportsdenied. - Re-grant and confirm the handler flips back to
grantedwithout 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)
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
- Enumerating devices once and caching forever. Without a
devicechangelistener, every plug/unplug leaves your device list and selection stale. Re-enumerate on each event. - Persisting
deviceIdwith{ exact }and not re-validating. Stored IDs rotate across updates and reloads (especially in Safari); request replacements with{ ideal }and re-check against a freshenumerateDevices(). - Tearing down the whole stream on unplug. Stopping the stream drops the renderer and forces signalling churn. Use
replaceTrack()to swap in a survivor and keep the peer connection alive. - Polling
getUserMedia()to detect permission state. This re-prompts and is wasteful. Usenavigator.permissions.queryplus itsonchangehandler, feature-detected for Firefox/WebKit gaps. - Ignoring the track
endedevent.devicechangetells you the device set changed; the track’s ownendedevent tells you this track died. Listen to both — they fire for different reasons and sometimes only one fires. - Assuming
devicechangecarries the changed device. The event has no payload. You must re-runenumerateDevices()and diff against your previous list to learn what actually changed.
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.