Media Constraints & Device Enumeration in WebRTC
Capturing camera and microphone input in a browser is deceptively simple to start — getUserMedia({ video: true }) returns a stream — and surprisingly hard to get right across the device matrix you actually ship to. This guide is part of the Media Handling, Codecs & Bandwidth Estimation guide, and it walks the full constraint lifecycle: discovering devices with enumerateDevices(), probing what the browser even supports via getSupportedConstraints(), requesting a stream with ideal/exact/min/max constraints, narrowing live tracks with applyConstraints(), and recovering cleanly when the browser rejects you with an OverconstrainedError. The goal is a capture path that lands on the resolution, frame rate, and DSP settings you intended on Chrome, Firefox, and Safari — and degrades predictably when it cannot.
The core mental model is negotiation, not assignment. You do not set a track’s width to 1280; you express a preference and the browser resolves it against hardware capabilities, OS-level locks, and concurrent tab usage. The diagram below traces that resolution from the constraint object you pass to the settings the track actually reports back through getSettings().
Step 1 — Enumerate devices and discover supported constraints
Two queries front-load every capture decision. navigator.mediaDevices.enumerateDevices() lists the cameras, microphones, and speakers attached to the host; navigator.mediaDevices.getSupportedConstraints() returns a flat object telling you which constraint names this browser understands at all. Run both before you build a constraint object, because requesting a property the browser silently ignores produces baffling negotiation results downstream.
Enumeration carries a privacy gate: before any media permission is granted, every MediaDeviceInfo has an empty label and a deviceId that is either empty or a per-session placeholder. You can count devices and read kind, but you cannot name them or address a specific one reliably. The practical consequence is that a device-picker UI populated before the first getUserMedia() call shows blank entries. Resolve it by checking permission state first (covered in Handling Device Hotplug & Permission Changes), by triggering a minimal capture to unlock labels and then re-enumerating, or by building a picker that degrades gracefully on placeholder data as described in Enumerating Devices Before Permission Is Granted. The field-by-field difference between the two states is what the picker has to survive.
// Run inside a secure context (HTTPS or localhost) — required for mediaDevices
async function discoverInputs() {
const supported = navigator.mediaDevices.getSupportedConstraints();
// supported.facingMode / supported.frameRate etc. are booleans per browser
console.log('Browser understands frameRate?', supported.frameRate === true);
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter((d) => d.kind === 'videoinput');
const mics = devices.filter((d) => d.kind === 'audioinput');
// Labels are '' until permission is granted; deviceId may be a placeholder
const labelled = cameras.every((c) => c.label !== '');
console.log(`cameras=${cameras.length} mics=${mics.length} labelled=${labelled}`);
// Group by groupId so a physical webcam with mic isn't shown twice
const byGroup = new Map();
for (const d of devices) {
if (!byGroup.has(d.groupId)) byGroup.set(d.groupId, []);
byGroup.get(d.groupId).push(d);
}
return { cameras, mics, groups: byGroup };
}
Gate on getSupportedConstraints() rather than assuming a property exists. Safari historically omitted resizeMode and lagged on aspectRatio; sending those keys to a browser that does not list them is not an error, it is a silent no-op that makes your negotiated settings drift from intent.
A second subtlety: enumerateDevices() returns audiooutput entries (speakers and headphones) only where the browser supports output-device selection, and even then only after permission unlocks them. Chrome exposes them and lets you route audio with HTMLMediaElement.setSinkId(); Safari historically did not surface audiooutput at all. Filter on kind defensively and treat an empty speaker list as “selection unsupported” rather than “no speakers”. Likewise, never index into the device array by position to find “the camera” — order is not guaranteed across browsers or reloads. Always filter by kind and address devices by deviceId or groupId.
Why the label gate exists, and how each browser draws it
The blanking is a fingerprinting defence, not an accident of the API. The set of devices attached to a machine — “Scarlett 2i2 USB”, “Logitech BRIO”, “Dell U2720Q (NVIDIA High Definition Audio)” — is unusually high-entropy: a handful of peripheral names identifies a workstation more precisely than user agent and screen size combined. So the spec exposes shape (how many inputs of each kind) without identity until the user has consciously granted the origin a capture permission.
deviceId is gated for the same reason and built the same way everywhere: a hash of the platform device identifier, salted per origin and per browser profile. Two origins see two different IDs for the same webcam, and clearing site data or resetting the permission rotates the salt — which is why an ID you persisted last week can vanish with the hardware unchanged. Nothing about it is portable, so never send one to your signalling server as a device fingerprint or key server-side configuration on it.
Browsers diverge on the scope of the unlock. Chromium reveals labels for all device kinds once any capture permission is granted for the origin, so a microphone-only grant lets you name the cameras too. Firefox scopes the reveal to the kind actually granted, so an audio-only app that later wants a camera picker still sees blank video rows. Probe the state rather than infer it, and wrap the probe: navigator.permissions.query({ name: 'camera' }) is unsupported in Firefox and rejects with a TypeError rather than returning a status object.
async function cameraPermissionState() {
// Permissions API for 'camera'/'microphone' is Chromium + Safari 16+ only
if (!navigator.permissions?.query) return 'unknown';
try {
const status = await navigator.permissions.query({ name: 'camera' });
return status.state; // 'granted' | 'denied' | 'prompt'
} catch {
return 'unknown'; // Firefox throws TypeError on this name
}
}
async function labelsAreVisible() {
const devices = await navigator.mediaDevices.enumerateDevices();
// Kind-scoped check: Firefox unlocks video labels only after a video grant
return devices.some((d) => d.kind === 'videoinput' && d.label !== '');
}
Treat 'unknown' as 'prompt' and drive the UI from labelsAreVisible(), which is behavioural rather than declarative and therefore correct on every engine.
Step 2 — Request a stream with ideal, exact, min, and max
The constraint qualifiers form a strict priority order, and choosing the wrong one is the single most common source of capture bugs. exact is a hard requirement — if the browser cannot satisfy it, the whole getUserMedia() call rejects with OverconstrainedError. min and max are inclusive bounds that also reject when unsatisfiable. ideal is a soft target: the browser gets as close as it can and never fails on it alone. A bare value (width: 1280) is treated as ideal.
The discipline that survives real device fragmentation: reserve exact for the few values that are genuinely non-negotiable (almost always just deviceId), express everything else as ideal, and add min/max only where out-of-range output would actually break your pipeline. Use exact on width or frameRate and you have hard-coded a failure on every laptop webcam that tops out a notch below your number — the qualifier-by-qualifier breakdown, including how to keep a strict deviceId without inviting rejections, is worked through in exact vs ideal Constraints Without OverconstrainedError.
async function acquireCamera(deviceId) {
const constraints = {
audio: {
echoCancellation: { ideal: true }, // soft — apply at capture, not after
noiseSuppression: { ideal: true },
autoGainControl: { ideal: true }
},
video: {
// exact deviceId: route to THIS camera or fail loudly (intended)
deviceId: deviceId ? { exact: deviceId } : undefined,
width: { min: 640, ideal: 1280, max: 1920 }, // bounded, soft target
height: { min: 480, ideal: 720, max: 1080 },
frameRate: { ideal: 30, max: 30 }, // never demand exact:30
facingMode:{ ideal: 'user' } // mobile fallback hint
}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// Always read back what you actually got — requested != applied
const settings = stream.getVideoTracks()[0].getSettings();
console.log('Negotiated:', settings.width, 'x', settings.height, '@', settings.frameRate);
return stream;
}
Apply audio DSP flags (echoCancellation, noiseSuppression, autoGainControl) in the getUserMedia call itself rather than via applyConstraints() afterward — passing them up front lets the platform engage hardware or driver-level processing it cannot retrofit onto an already-running track. The same up-front rule applies in reverse when you need the DSP chain off entirely, as Disabling Audio Processing for High-Fidelity Music covers for instrument and music capture. The cross-device audio specifics live in Audio/Video Track Management.
A note on facingMode versus deviceId. On mobile, facingMode: { ideal: 'user' } (front) or 'environment' (rear) is the portable way to pick a camera because the OS owns the physical mapping and deviceId values are opaque and unstable there. On desktop, deviceId is the deterministic choice because users have named, persistent webcams. The robust pattern is to prefer an explicit deviceId when you have a validated one and fall back to facingMode when you do not — which is exactly the shape of the constraint object above, where deviceId is conditionally included and facingMode is always present as a hint. Requesting both is safe: the browser honours exact: deviceId first and uses facingMode only to break ties.
The advanced array: ordered, greedy, and silently skipped
There is a fifth qualifier that almost nobody uses and that solves the fallback problem in a single call. Alongside the basic constraints you may pass advanced, an ordered array of constraint sets. The browser satisfies the basic set first, then walks the advanced sets in order, adopting each one only if it can be met in full and simultaneously with everything already adopted. A set that cannot be satisfied is discarded without error, and the walk continues to the next. Inside an advanced set, a bare value behaves like exact rather than ideal — that inversion is the reason the feature confuses people who try it once and abandon it.
The behaviour is a priority ladder expressed declaratively: put your most desirable configuration first and progressively cheaper ones after it, and the browser lands on the best rung the hardware supports without a single rejection.
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { min: 640, ideal: 1280 }, // basic set — must hold, ideal is soft
height: { min: 360, ideal: 720 },
advanced: [
{ width: 1920, height: 1080, frameRate: 60 }, // rung 1: adopted only if all three fit
{ width: 1920, height: 1080, frameRate: 30 }, // rung 2
{ width: 1280, height: 720, frameRate: 60 } // rung 3
]
}
});
// Silent skipping means read-back is mandatory to know which rung landed
const s = stream.getVideoTracks()[0].getSettings();
console.log(`landed on ${s.width}x${s.height}@${s.frameRate}`);
Support is uneven: Chromium implements the ordered walk faithfully, Firefox honours the array but resolves conflicts with the basic set differently, and Safari has historically ignored advanced altogether — harmless, since ignoring it degrades to the basic set rather than failing. The payoff over a sequential try/catch ladder is real, because every rejected getUserMedia() attempt costs a full device open and close, roughly 150–400 ms on a desktop UVC webcam and 300–800 ms on a mobile camera HAL. A three-tier fallback that misses twice can burn most of a second with the capture indicator blinking; one advanced call spends a single device open. Keep the try/catch ladder as the outer net for OverconstrainedError on the basic set, and let advanced resolve preference ordering inside it.
Step 3 — Narrow live tracks with applyConstraints, validating against capabilities
Once a track is live you can re-negotiate it without tearing down the stream by calling track.applyConstraints(). This is how you step a video track down to a lower resolution under thermal pressure, or up after a network recovery, while keeping the same MediaStreamTrack — which matters because replacing the track forces renderer resets and, on a peer connection, signalling churn you usually want to avoid (see Replacing Video Tracks Without Renegotiation for when a swap is genuinely warranted).
Before you apply, validate the request against track.getCapabilities(), which reports the concrete ranges this specific track supports — { width: { max: 1920 }, frameRate: { max: 30 }, ... }. Checking capabilities turns an asynchronous OverconstrainedError into a synchronous decision you control.
async function stepDownResolution(track) {
const caps = track.getCapabilities(); // hardware truth for THIS track
// Clamp our target into what the device can actually deliver
const targetW = Math.min(960, caps.width?.max ?? 960);
const targetH = Math.min(540, caps.height?.max ?? 540);
try {
await track.applyConstraints({
width: { ideal: targetW },
height: { ideal: targetH },
frameRate: { ideal: 24, max: 24 }
});
console.log('Now at', track.getSettings().width, '×', track.getSettings().height);
} catch (err) {
// OverconstrainedError here means even the clamped value was unmet
console.warn('applyConstraints rejected:', err.constraint, err.message);
}
}
Apply changes incrementally. Mutating width, height, and frameRate in a single call can trigger an encoder re-initialisation that drops several frames; if you only need to drop frame rate, change only frame rate. Note also that applyConstraints() adjusts the capture track — it does not directly set the encoder bitrate ceiling. Bitrate is governed separately through RTCRtpSender.setParameters(), the province of Bandwidth Estimation & Congestion Control, and the two must be kept consistent so the encoder is not asked to encode 1080p frames into a 300 kbps ceiling.
What the capture pipeline actually does when constraints change
Incremental changes matter because applyConstraints() resolves onto one of two very different implementation paths, and the caller cannot tell which without inspecting the result. The cheap path is post-capture scaling: the camera keeps producing its current native format and the browser crops or downscales frames in software before they reach the encoder. The expensive path is native reconfiguration — the browser closes the capture device, reopens it at a different hardware format, and restarts the pipeline, typically 100–300 ms with no frames on desktop and longer on mobile. Because the encoder is re-initialised behind that gap, the first frame after it is a keyframe, and keyframes commonly run five to ten times the size of an inter frame at the same resolution. A resolution step taken under congestion therefore produces a bitrate spike exactly when the link can least absorb one.
Which path you get depends on whether your requested dimensions match a format the camera advertises. Ask for 1280×720 or 640×480 and you are on native boundaries; ask for 1000×562 and no such mode exists, so the browser opens the nearest larger format and scales in software, burning capture-thread CPU on every frame for the life of the track. resizeMode is the explicit control: 'crop-and-scale' permits the software path, 'none' forbids it and pins you to native formats, making getSettings() far more predictable and OverconstrainedError far more likely — a trade worth taking on kiosk hardware you control and avoiding on the open web.
Frame rate is the asymmetry to exploit: lowering frameRate is nearly always implemented as frame dropping on the existing format, with no device reopen, no keyframe and no gap, while lowering resolution usually is not. Shed frames first when relieving thermal or CPU pressure — 30 to 20 fps costs nothing in the pipeline — and step resolution only when you need encoder complexity reduction that frame dropping cannot deliver. Keep in mind that on a peer connection the encoder is already adapting on its own, lowering the sent resolution or frame rate from CPU and congestion signals independently of the capture track, so a manual step-down can stack on top of an adaptation already applied and read to the user as a sudden quality collapse. Decide which layer owns degradation first — that choice is the subject of degradationPreference: Resolution vs Framerate.
Step 4 — Verification and overconstrained handling
Verification closes the loop between what you asked for and what you got, and it is non-optional because the browser will happily hand you a downgraded track without raising an error. The three-method triad makes the gap visible: getCapabilities() (what the hardware can do), getConstraints() (what you asked for), and getSettings() (what you actually got).
Overconstrained handling is the failure path. When getUserMedia() or applyConstraints() rejects with OverconstrainedError, the error’s .constraint field names the exact property that could not be met — "width", "deviceId", "frameRate". The recovery pattern is a fallback chain that relaxes from strictest to loosest tier, and an isolation pass that applies constraints one at a time to identify precisely which one is at fault.
const tiers = [
{ video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } } },
{ video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } } },
{ video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15 } } }
];
async function acquireWithFallback() {
for (const tier of tiers) {
try {
const stream = await navigator.mediaDevices.getUserMedia(tier);
const s = stream.getVideoTracks()[0].getSettings();
console.log('Acquired tier ->', s.width, 'x', s.height, '@', s.frameRate);
return stream;
} catch (err) {
if (err.name === 'OverconstrainedError') {
console.warn('Tier failed on constraint:', err.constraint, '— relaxing');
continue; // try the next, looser tier
}
throw err; // NotAllowedError / NotFoundError — do not retry
}
}
throw new Error('No constraint tier was satisfiable on this device');
}
Distinguish OverconstrainedError from the other rejection names before you retry: NotAllowedError means the user denied permission and retrying with looser constraints is pointless, while NotFoundError means no matching device exists. Only OverconstrainedError warrants relaxing and re-requesting. Trace the rejected constraint live in chrome://webrtc-internals or Firefox’s about:webrtc to confirm which value the browser balked at. The branch is small enough to encode directly in the catch block:
Failure mode: the live-but-frozen track after device preemption
The rejection paths above are the easy failures because they are loud. The nastiest capture failure in production is silent: the track reports readyState === 'live', the video element holds its last painted frame, no promise rejects, no ended event fires, and the far end sees a still image indefinitely. That is preemption — something else took the device. The usual causes are another application opening a UVC camera that supports only one client, a lid close or screen lock suspending the camera on macOS, a hardware privacy shutter or OS kill-switch, a Windows application seizing the microphone in exclusive mode, and a backgrounded mobile tab losing the camera to the system camera app.
The signal that does fire is the track’s mute event and its muted property, which flips to true while readyState stays 'live' — ended is reserved for physical removal or an explicit stop(). Chromium raises mute/unmute reliably for camera preemption; Firefox has historically been inconsistent, so an event-only detector misses failures on a browser you certainly ship to. The portable detector is a stats watchdog: poll getStats() at 1 s intervals, take the delta of framesEncoded (sender side) or framesDecoded (receiver side) between polls, and treat three consecutive zero deltas as a freeze — long enough to survive a keyframe request or brief encoder stall, short enough to act before a user files a ticket.
Recovery is a re-acquire, not a re-constrain: the handle you hold is dead, so applyConstraints() on it does nothing. Stop the track, back off 3–5 s so you are not spinning against a still-busy device, call getUserMedia() again with the same deviceId and your existing fallback tiers, and hand the new track to the sender with replaceTrack() so no renegotiation is needed. Log the muted transition next to the freeze detection: both together means preemption, whereas frames stopping while muted stays false points at the encoder or the network rather than the capture device.
Edge Cases & Browser Quirks
- Safari (WebKit) deviceId churn. Safari rotates
deviceIdvalues more aggressively than Chromium across page loads and after permission resets, so a persistedexact: deviceIdfrom a previous session can produceOverconstrainedErrorwith.constraint === 'deviceId'. Always keep afacingModeor unconstrained fallback tier, and re-validate stored IDs against a freshenumerateDevices()on load; when the failure only reproduces on an iPhone, the remote-inspector workflow in Debugging WebRTC on Safari and iOS WKWebView is the fastest way to see whichdeviceIdthe page actually sent. - Firefox and
exactframe rate. Firefox is stricter than Chrome aboutframeRate: { exact: 30 }on cameras whose driver reports 29.97; the call rejects where Chrome quietly rounds. Useidealplusmaxinstead. - Chrome silent resolution snapping. Chrome snaps requested dimensions to the nearest capture format the camera advertises, so
width: { ideal: 1000 }may yield 1280 or 960. Never assumegetSettings().widthequals yourideal— always read it back. - Mobile hardware pipeline locks. Mobile Chrome and Safari can refuse
applyConstraints()mid-stream because the camera pipeline is locked at the resolution chosen atgetUserMedia()time. Validate againstgetCapabilities(); if the range collapses to a single value, plan to re-acquire rather than re-constrain. getSupportedConstraints()gaps.resizeMode,aspectRatio, andpan/tilt/zoomsupport varies by browser and OS. Feature-detect each before use; an unsupported key is silently dropped, not flagged.groupIdfor de-duplication. A single physical webcam often exposes pairedaudioinputandvideoinputentries sharing agroupId. Group by it to avoid showing the same device twice in a picker.- Windows virtual
defaultandcommunicationsdevice IDs. Chrome on Windows surfaces two syntheticaudioinputentries with the literal IDsdefaultandcommunications, aliases that follow whatever the OS currently designates. Persistingdefaultlooks stable and is not — plug in a headset, Windows moves the default, and the same stored ID now resolves to different hardware. Store the real device’s ID for a deliberate user choice and reservedefaultfor an explicit “follow system” option. pan,tilt, andzoomneed their own permission. Chromium gates PTZ behind a separate grant requested viagetUserMedia({ video: { pan: true, tilt: true, zoom: true } }), which raises a distinct prompt. A track acquired without it never lists PTZ ingetCapabilities(), so feature-detect against capabilities rather thangetSupportedConstraints()— the latter only says the browser knows the name, not that this track can use it.- Screen capture ignores the device constraint vocabulary.
getDisplayMedia()accepts a superficially similar constraint object but silently dropsdeviceId,facingMode, andgroupId; the surface is chosen by the user in the browser’s own picker, andwidth/heightact as post-capture bounds rather than a request to the source. Screen content also wants different processing from camera content, which is what Screen Sharing & Content Hints exists to configure.
Common Implementation Mistakes
- Reading
device.labelbefore permission. It is an empty string until the user grants access. Check permission state or trigger a minimal capture, then re-enumerate. - Using
exactwhereidealbelongs.exact: { width: 1280 }rejects on any device that cannot hit exactly 1280. ReserveexactfordeviceIdand useideal(optionally bounded bymin/max) for dimensions and frame rate. - Trusting requested values without reading
getSettings(). The browser downgrades silently. The only source of truth for what a track is actually producing isgetSettings(). - Skipping
getCapabilities()beforeapplyConstraints(). Requesting a value outside the track’s supported range throws asynchronously; a synchronous capability check is cheaper and clearer. - Replacing the track to change a setting. Swapping the
MediaStreamTrackfor a resolution tweak causes renderer flicker and needless signalling. UseapplyConstraints()for in-place changes. - Treating every rejection as overconstrained. Retrying with looser constraints after a
NotAllowedError(permission denied) just fails again. Branch onerr.namefirst. - Leaving the label-probe stream running. Capturing purely to unlock labels and forgetting to
stop()every track leaves the capture indicator lit, holds a single-client camera against your own later request, and reads to the user as covert recording. Stop probe tracks in afinallyblock before populating the picker. - Re-enumerating on every
devicechangewithout debouncing. One docking station can emit three to sixdevicechangeevents within a couple of hundred milliseconds as each endpoint registers, rebuilding the picker mid-interaction and discarding a selection the user just made. Debounce 300–500 ms and enumerate once on the trailing edge. - Assuming
getCapabilities()is static. It describes the track as currently configured, not the device in the abstract; after a mobile pipeline lock or a native-format change the reported maxima can shrink. Re-read it after any successfulapplyConstraints().
FAQ
What is the practical difference between ideal and exact?
exact is a hard requirement — getUserMedia() rejects with OverconstrainedError if it cannot be met. ideal is a soft target the browser approximates and never fails on by itself. Use exact only for values that must match (typically deviceId); use ideal for everything tunable.
Why does getSettings() not match what I requested?
Constraints are negotiated, not assigned. The browser resolves your ideal values against hardware capabilities and may snap to the nearest supported capture format. getSettings() reports the negotiated truth — always read it back rather than assuming your request was honoured verbatim.
How do I know which constraint caused an OverconstrainedError?
Read the error’s .constraint property — it names the exact failing constraint (e.g. "frameRate"). For ambiguous cases, apply constraints one at a time to isolate the culprit, and confirm in chrome://webrtc-internals or about:webrtc.
Should I persist deviceId across sessions?
Yes, but always re-validate. Store the deviceId and re-check it against a fresh enumerateDevices() on load, with a facingMode or unconstrained fallback, because IDs can change after browser updates, OS audio-stack resets, or in Safari across reloads.
Does releasing a device require stopping the track, or is muting enough?
track.enabled = false keeps the device open and merely replaces outgoing frames with black or silence, so the camera light stays on and the hardware stays claimed by your page. Only track.stop() closes the capture device and frees it for another application, at the cost of a re-acquire and a 150–400 ms device open when the user unmutes. Choose per surface: a quick in-call mute should keep the device, while leaving a lobby should release it. The full comparison is in Muting Tracks vs Stopping Them.
Why does a second getUserMedia() call return a different resolution than the first?
Because the device is already open at a chosen native format. When a second call targets the same camera while the first track is live, browsers generally reuse the running format rather than reconfiguring hardware underneath an existing consumer, so the new constraints are satisfied by cropping and scaling from that format. Your resolution becomes a function of what the earlier caller asked for. If two parts of an application need genuinely different formats, acquire once and derive the second view from the same track instead of opening the camera twice.
Related: return to Media Handling, Codecs & Bandwidth Estimation, then read Handling Device Hotplug & Permission Changes, Audio/Video Track Management, and Adaptive Bitrate Streaming in WebRTC.