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.
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.
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
- Open the page in a fresh profile or an incognito window so the origin starts at
prompt, and log(await navigator.mediaDevices.enumerateDevices()).lengthbefore touching anything. - Log the same array with
console.table()and confirm everylabel,deviceIdandgroupIdrenders as an empty string, and that the length equals the number of kinds present, not devices. - Log
(await navigator.permissions.query({ name: 'camera' })).stateinside atry/catch; in Firefox record theTypeErrorrather than swallowing it, so you can see that theunknownpath is the one being taken. - 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.
- Revoke access from the address-bar control without reloading, and confirm your
PermissionStatus.onchangehandler 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.
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
- Treating list length as a device count. Pre-grant,
videoinputentries number one regardless of whether zero, one or four cameras exist beyond the first. The only pre-grant fact is presence of a kind. - Using label emptiness as the sole permission signal. It correctly detects “not granted”, but it cannot separate
promptfromdenied, so a blocked user gets an Allow button that silently rejects. Pair it with the query and treat a thrown query asprompt. - Calling
getUserMedia()when the state is alreadydenied. The promise rejects withNotAllowedErrorwithout any dialog, so the user sees a spinner resolve into nothing. Render the settings-reset instructions instead. - Stopping the warm-up track before building the real stream. You pay a second 200–500 ms device open, the capture indicator flickers, and on some Android builds the reacquire fails outright while the first track is still tearing down. Reuse the track you already have.
- Persisting only the
deviceId. Salts rotate. StoregroupIdandlabelalongside it and resolve through a fallback chain, and re-run the resolution on everydevicechange— the surrounding event plumbing is covered in Handling Device Hotplug & Permission Changes.
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.