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) or by triggering a minimal capture to unlock labels, then re-enumerating.
// 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.
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.
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 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.
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.
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.
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. - 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.
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.
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.
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.