Enumerating Devices Before Permission Is Granted

Call navigator.mediaDevices.enumerateDevices() on a cold page load and it resolves happily — with a redacted list. Every label is an empty string, every deviceId and groupId is an empty string, and the array holds at most one entry per device kind no matter how much hardware is attached. This guide is part of the Media Constraints & Device Enumeration guide, and it answers one question: what can you honestly build out of that redacted list, and how do you find out which side of the permission boundary you are standing on before you call getUserMedia().

Context & Trade-offs

The redaction is a fingerprinting defence, and the entropy it protects is substantial. A docked laptop typically exposes 6–10 device entries after a grant — an internal camera plus a USB webcam, an internal mic, a headset mic and a virtual-conferencing mic, and three or four audio outputs — and the exact tuple of product names is close to a unique identifier for that machine. So the specification requires the browser to expose, absent permission, “at most one device of each kind” with blanked identifiers. You learn one bit per kind: whether any camera exists, whether any microphone exists. You do not learn how many, which, or what they are called.

This matters because the blanked list is genuinely ambiguous in the direction that hurts. A machine with two cameras and a machine with one camera produce byte-identical pre-grant output. A machine whose camera permission was permanently blocked also produces byte-identical output — enumerateDevices() does not distinguish prompt from denied. Only a Permissions API query separates those two, and its support for the camera and microphone names is uneven:

Browser Pre-grant entries permissions.query({ name: 'camera' }) deviceId after grant
Chrome / Edge one per present kind, all fields empty supported; onchange fires on revoke salted per origin, stable until site data is cleared
Firefox one per present kind, all fields empty rejects with TypeError on the name salted per origin; resets when a one-off grant lapses
Safari 17+ one per present kind, all fields empty camera/microphone are not queryable names may rotate across reloads unless the grant is persistent

Concretely, here is the same physical machine on both sides of a single successful getUserMedia() call — two cameras and two microphones that pre-grant look like one of each, with nothing you could key a UI off.

MediaDeviceInfo before and after the grant Two record listings for the same laptop. The left panel, in permission state prompt, shows two entries with kind videoinput and audioinput and empty deviceId, groupId and label strings. The right panel, after a grant, shows four entries with salted device identifiers and real product names. An arrow between them is labelled with a single getUserMedia call. The same laptop, one getUserMedia() apart two cameras and two microphones are attached in both panels permission: prompt [ { kind: "videoinput", deviceId: "", groupId: "", label: "" }, { kind: "audioinput", deviceId: "", label: "" } ] length 2 — one entry per present kind outputs are hidden entirely on Safari permission: granted [ { kind: "videoinput", deviceId: "3f9a...c21", groupId: "7b0e...44", label: "C920 HD Pro (046d:08e5)" }, { kind: "videoinput", ... }, { kind: "audioinput", ... } x2 ] length 6 — true count, real names ids are salted per origin, not global grant gUM() The array you already hold is never patched in place — re-run enumerateDevices() once the grant resolves
Pre-grant entries are placeholders for a kind, not handles to a device: there is nothing in them to select on.

That leaves two viable designs and one popular non-design. The non-design is to render a <select> populated from the pre-grant array, which produces a dropdown containing one option reading “Camera 1” that maps to no camera in particular. The first real option is to prompt immediately on page load: you get the full list within a few hundred milliseconds, at the cost of a permission dialog with no context, which is the single largest driver of hard denials — and a denial is expensive because it is sticky per origin and can only be cleared from browser settings. The second, and the one worth building, is a permission-first picker: use the blanked list purely as a capability probe (“this machine has a camera”), render one placeholder row per kind with the browser’s own default as the implied selection, and make the primary action a single Allow camera and mic button that runs one warm-up getUserMedia(). Keep the resulting track alive as your local preview rather than stopping it and reopening the device for the real call — a camera open costs roughly 200–500 ms on internal hardware and can approach a second on a USB webcam, and the indicator light blinking off and on again reads as a bug to users.

Minimal Runnable Implementation

// Reports which side of the permission boundary we are on, without prompting.
async function probePermission(name) {         // name: 'camera' | 'microphone'
  if (!navigator.permissions?.query) return 'unknown';   // no Permissions API
  try {
    const status = await navigator.permissions.query({ name });
    status.onchange = () => renderPicker();    // revoke or later grant, no reload
    return status.state;                       // 'granted' | 'prompt' | 'denied'
  } catch {
    return 'unknown';                          // Firefox/Safari: name not queryable
  }
}

// A blank label on a device that exists is the ground truth for "not granted yet".
async function readDevices() {
  const devices = await navigator.mediaDevices.enumerateDevices();
  const cams = devices.filter((d) => d.kind === 'videoinput');
  return {
    cams,
    hasCamera: cams.length > 0,                // one bit — never a count, pre-grant
    labelled: cams.some((d) => d.label !== '') // '' means redacted, not unnamed
  };
}

async function renderPicker() {
  const [perm, { cams, hasCamera, labelled }] = await Promise.all([
    probePermission('camera'), readDevices()
  ]);

  if (perm === 'denied') return showBlocked();       // do NOT call gUM: it insta-rejects
  if (!hasCamera) return showNoHardware();           // genuinely zero videoinput entries
  if (!labelled) return showPlaceholders(cams.length); // prompt | unknown: 1 fake row
  return showRealPicker(cams);                       // granted: real names and ids
}

// Runs once, from a user gesture. The track becomes the preview — never stopped here.
async function unlock() {
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: true,
    video: { deviceId: { ideal: localStorage.getItem('camId') ?? undefined } }
  });
  preview.srcObject = stream;                  // reuse the warm-up capture as-is
  await renderPicker();                        // labels are populated from here on
  return stream;
}

The branch order is load-bearing: denied is checked before hardware presence, because a blocked origin and a machine with no webcam produce the same enumeration and only the query distinguishes them. Where the query returns unknown — Firefox and Safari today — the code deliberately collapses unknown into the same placeholder path as prompt, which is the correct pessimistic default: showing an Allow button to a user who has already granted access is a one-render mistake that the post-grant re-enumeration immediately corrects, whereas assuming granted and rendering an empty picker is a dead end.

Which picker to render before calling getUserMedia A tree rooted at a combined permissions query and enumerateDevices call. It branches three ways into granted, prompt or unsupported query, and denied. Each branch leads to a render outcome: full picker, placeholder picker with an Allow action, or a blocked panel. A feedback arrow shows the placeholder branch upgrading into the full picker once the grant resolves. Choosing what to render before you call getUserMedia() the branch is decided by permission state, never by the length of the device list permissions.query + enumerate both run on load, in parallel granted prompt, or query unsupported denied Full picker real labels, real count restore the saved deviceId fall back to a groupId match Placeholder picker one row per kind, no names primary action: Allow no select of invented rows Blocked panel never call getUserMedia show the address-bar reset keep onchange armed on grant: keep the warm-up track, re-enumerate then upgrade this picker in place, without a reload
Four outcomes, one of which is transient: the placeholder branch exists only to buy the grant that promotes it.

Restoring a persisted selection needs the same pessimism. A stored deviceId is a salted, origin-scoped string, not a hardware serial, and it changes when the user clears site data, when a one-off Firefox grant lapses, and — as catalogued in Debugging WebRTC on Safari and iOS WKWebView — sometimes across a plain reload. So persist the whole tuple (deviceId, groupId, label, kind) and resolve it after the grant through a fallback chain: exact deviceId hit, else a groupId hit (which survives ID rotation and correctly re-pairs a headset’s mic with its speaker), else a label match, else the browser default. Feed the winner to getUserMedia() as deviceId: { ideal }, because { exact } on a rotated ID throws OverconstrainedError and strands the user on a black preview — the full decision procedure is in exact vs ideal Constraints Without OverconstrainedError. The groupId link is also what lets you avoid pairing a USB headset’s microphone with the laptop’s speakers, a mismatch that wrecks echo cancellation for the reasons set out in Audio Focus & Echo Cancellation Across Devices.

Reproduction Steps & Debugging Log Patterns

  1. Open the page in a fresh profile or an incognito window so the origin starts at prompt, and log (await navigator.mediaDevices.enumerateDevices()).length before touching anything.
  2. Log the same array with console.table() and confirm every label, deviceId and groupId renders as an empty string, and that the length equals the number of kinds present, not devices.
  3. Log (await navigator.permissions.query({ name: 'camera' })).state inside a try/catch; in Firefox record the TypeError rather than swallowing it, so you can see that the unknown path is the one being taken.
  4. Click Allow, then re-enumerate and diff the two arrays. Note the length jump and that the placeholder entries are not “filled in” — they are replaced by a wholly new set of objects.
  5. Revoke access from the address-bar control without reloading, and confirm your PermissionStatus.onchange handler fires and re-renders into the blocked panel.

Expected healthy log across the boundary:

// before the grant
// devices: 2         labels: ["", ""]        ids: ["", ""]
// permission(camera): prompt                 // Firefox prints: unknown (TypeError)
// render: placeholders  rows=2  allowButton=true
// --- user clicks Allow, one getUserMedia() ---
// devices: 6         labels: ["C920 HD Pro", "FaceTime HD", ...]
// permission(camera): granted                // onchange fired, no reload
// restore: saved deviceId miss -> groupId hit -> using "C920 HD Pro"
// render: realPicker  rows=6  previewTrack=reused  (no second device open)

The state transitions behind those lines are worth holding in your head as a machine, because two of the three states look identical from enumerateDevices() alone and one of them is a trap door.

Permission lifecycle and what each state leaks Three states. Prompt and denied both return one redacted entry per kind, while granted returns every device with salted identifiers and product names. Accepting moves prompt to granted, a lapsed session returns granted to prompt, revoking moves granted to denied, and only a site settings reset returns denied to prompt. Permission lifecycle, annotated with what enumerateDevices() returns prompt and denied are indistinguishable from the device list alone prompt 1 entry per kind deviceId: empty label: empty count: unknown granted every device listed deviceId: salted label: product name groupId pairs in + out denied 1 entry per kind same bytes as prompt gUM rejects at once NotAllowedError accept session end revoke only a site settings reset returns a blocked origin to prompt Firefox: a grant without "Remember this decision" reverts to prompt when the last track stops Safari: labels come back after a reload, but the deviceId values behind them may not
The dangerous edge is the one that never fires an event: a lapsed grant silently re-blanks every label.

A broken implementation has a recognisable signature. If you see devices: 2 followed immediately by render: realPicker rows=2, your labelled-check is testing cams.length > 0 instead of label !== '', and you are about to show two nameless rows. If permission(camera) never prints at all, the query call threw outside your try block and killed the render pass. And if the post-grant enumeration still shows blank labels in Chrome, the page is almost certainly not on a secure origin — mediaDevices is only exposed on HTTPS or localhost, and a LAN IP over plain HTTP gives you a permanently redacted list with no error to explain it.

Common Implementation Mistakes

FAQ

Can I count how many cameras a machine has without prompting?

No. That count is exactly the fingerprinting surface the redaction exists to hide, and every current browser caps the pre-grant list at one entry per kind. You can detect that a kind is present, and nothing more — design the pre-grant UI so it never needs the number.

Does requesting audio-only permission unlock camera labels too?

Not reliably, and you should not depend on it. Chrome historically unlocked labels for all kinds once any capture permission was held, while Firefox and Safari scope the reveal to the kind that was granted. If your picker needs both camera and microphone names, request both in one getUserMedia({ audio: true, video: true }) call, which also costs the user only one dialog.

Why do my labels go blank again after the call ends?

Almost always a non-persistent grant. In Firefox, a permission given without “Remember this decision” reverts to prompt when the last track stops, and Safari behaves similarly across some session boundaries. Nothing fires to tell you — re-run the enumeration when your capture ends and re-render rather than trusting the labels you cached at join time.

Related: return to Media Constraints & Device Enumeration, and read Audio/Video Track Management and Muting Tracks vs Stopping Them.