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().

Constraint resolution pipeline Requested constraints pass through supported-constraint filtering and capability matching, producing either an OverconstrainedError or negotiated track settings reported by getSettings. Requested ideal / exact min / max Supported? getSupported- Constraints() Capability match against getCapabilities() Negotiated track settings getSettings() Rejected Overconstrained- Error.constraint exact unmet
Requested constraints are filtered by browser support, matched against hardware capabilities, and resolve to negotiated settings — or, when an `exact` value cannot be met, to an `OverconstrainedError` naming the offending constraint.

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.

MediaDeviceInfo record before and after permission Side-by-side field layout of one MediaDeviceInfo entry: before permission the label is empty and the deviceId is a rotating placeholder, while after a successful getUserMedia call the label is human readable and the identifiers are stable for the origin. enumerateDevices() — same entry, two permission states Before permission kind "videoinput" deviceId "" or placeholder label "" (blank row) groupId "" — cannot pair Picker can only show device counts After getUserMedia() grant kind "videoinput" deviceId stable per origin label "FaceTime HD Camera" groupId pairs cam + mic Re-enumerate here to refresh the picker
The same `MediaDeviceInfo` entry before and after a permission grant — only `kind` is trustworthy in the left state, which is why a picker must be repopulated by re-enumerating after capture starts.
// 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.

Constraint qualifier comparison matrix Matrix comparing exact, min and max, ideal, and bare-value qualifiers across their negotiation semantics, whether they can reject getUserMedia, and the properties each is appropriate for. Qualifier Negotiation semantics Can reject? Correct use exact Hard requirement; no approximation allowed Yes deviceId only min / max Inclusive bounds; rejects when range is unmeetable Yes Pipeline hard limits ideal Soft target; browser lands on the nearest format No width, height, frameRate width: 1280 Bare value — spec treats it exactly as ideal No Shorthand, avoid ambiguity
Only `exact` and `min`/`max` can reject a capture request; a bare value is a synonym for `ideal`, which is why `width: 1280` never fails on its own.
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:

Rejection-handling decision tree A getUserMedia rejection branches on err.name into OverconstrainedError, which relaxes to the next constraint tier and retries at most three times, and NotAllowedError or NotFoundError, which must stop retrying and wait for user action or a devicechange event. getUserMedia() rejects branch on err.name OverconstrainedError .constraint names the field NotAllowedError user denied the prompt NotFoundError no device of that kind Relax and retry 1080p → 720p → 480p max 3 attempts, then give up Stop retrying show permission help UI re-request on user gesture Stop retrying wait for devicechange then re-enumerate
Only the left branch is retryable — looping the tier fallback after a `NotAllowedError` or `NotFoundError` just replays the same failure.

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

Common Implementation Mistakes

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.