exact vs ideal Constraints Without OverconstrainedError

OverconstrainedError is not a bug in your constraint object; it is the browser telling you that no capture format on the attached hardware scored a finite match against something you marked as mandatory. This guide is part of the Media Constraints & Device Enumeration guide, and it answers one question: given a wish list of resolution, frame rate, and device identity, which entries can you safely mark mandatory, and what should happen the moment one of them does not survive contact with a real webcam.

Context & Trade-offs

The resolution algorithm is worth knowing precisely, because every mitigation follows from it. The browser assembles the list of capture formats the driver advertises for the chosen device, then computes a fitness distance for each candidate format against each constraint you supplied. A value that violates an exact, min, or max term scores infinite distance and eliminates that format outright. An ideal term never eliminates anything: it contributes |actual − ideal| / max(|actual|, |ideal|), a number between 0 and 1. The browser keeps the surviving format with the lowest summed distance. If nothing survives, you get OverconstrainedError, and err.constraint names the term that did the eliminating.

That is why exact behaves so badly on real hardware. A USB Video Class camera does not expose a continuous range; it publishes a descriptor list of four to eight discrete formats — commonly 320×240, 640×480, 1280×720, and 1920×1080 — and mandatory terms must land on a member of that set. Request width: { ideal: 1000 } and the algorithm picks 1280 with a distance of 0.22. Request width: { exact: 1000 } on the same camera and every candidate scores infinity.

Fitness distance across a camera's discrete format list A width axis marked with the four capture formats a typical webcam advertises. A request for 1000 pixels sits between two of them: expressed as ideal it scores a finite distance and snaps to 1280, while expressed as exact it scores infinite distance against every format and raises OverconstrainedError. A webcam publishes a format list, not a range the same number 1000 is either a hint or a guaranteed rejection width: { exact: 1000 } distance = Infinity on all four rejects, err.constraint = "width" width: { ideal: 1000 } 280 / 1280 = 0.22 wins capture opens at 1280 320 30 fps 640 30 fps 1280 29.97 fps 1920 15 fps requested 1000 snaps right Mandatory terms filter the list; ideal terms only rank what survives.
Fitness distance is computed per format: `exact` can empty the candidate set, `ideal` can only reorder it.

Frame rate is worse than resolution because the numbers are not round. Drivers report 29.97, 20.000004, or 59.94, so frameRate: { exact: 30 } fails on hardware that is, for every practical purpose, a 30 fps camera. Chrome hides some of this: with resizeMode defaulting to crop-and-scale, Chromium will downscale a native 1280×720 frame to satisfy width: { exact: 640 }, which is exactly why a mandatory width that works in Chrome raises OverconstrainedError in Firefox, where the same request must match a native format.

Constraint Why exact fails on shipped hardware Safer form
deviceId IDs rotate across reloads, OS resets, browser updates exact plus a validated fallback rung
width / height UVC advertises 4–8 discrete formats only ideal bounded by max
frameRate drivers report 29.97 or 20.000004, not 30 or 20 ideal plus max
facingMode desktops expose no 'environment' camera at all ideal
aspectRatio 4:3 sensor cannot produce a native 1.7777 ideal plus resizeMode
echoCancellation some Android hardware AEC cannot be switched off ideal

min and max deserve their own warning, because they read like hints and behave like requirements. A bare width: { min: 1280 } is every bit as mandatory as exact, and on a 720p laptop sensor it eliminates the entire format list just as thoroughly. The distinction that matters is directional: max is nearly always safe, because a camera that cannot reach your ceiling is still under it, while min asserts a floor the hardware may simply not have. Write bounds as { ideal: 1280, max: 1920 } and reserve min for the rare case where output below a threshold would genuinely break a downstream consumer — a QR decoder that needs 640 pixels of width, say, where a smaller frame is worse than no frame.

The same asymmetry applies on the audio side, where the failures are quieter. sampleRate: { exact: 48000 } rejects on Windows devices whose shared-mode mix format is pinned at 44100 by the OS, and channelCount: { exact: 2 } rejects on the majority of built-in laptop microphones, which are mono. Both are usually requested by developers reaching for higher fidelity, and both are better expressed as ideal with a readback check — the fidelity path is worked through in Disabling Audio Processing for High-Fidelity Music.

The one term that earns exact is deviceId, and only because the alternative is worse: with ideal, a stale identifier silently opens a different camera, and the user watches the wrong lens go live. Keeping it mandatory turns a silent misroute into a catchable rejection — provided you actually catch it, which is the job of the ladder below. The cost of each rejected attempt is real: a failed getUserMedia() still opens and probes the device, costing 100–400 ms on desktop and 300–600 ms on a mobile camera HAL cold start. Three relaxation attempts is the sensible cap; it keeps worst-case acquisition around 1.5 s, comfortably inside the 3–5 s budget a join flow can absorb.

Minimal Runnable Implementation

A blind resolution ladder — 1080p, then 720p, then 480p — wastes attempts because it relaxes properties that were never at fault. err.constraint tells you which term eliminated every candidate, so use it to jump directly to the rung that loosens that term.

const savedCamId = localStorage.getItem('camId') || '';

// What we would take if the hardware were perfect. Note: only deviceId is
// mandatory by intent — everything else starts exact so the ladder can measure.
const base = {
  video: {
    deviceId:   { exact: savedCamId },
    width:      { exact: 1280 },
    height:     { exact: 720 },
    frameRate:  { exact: 30 },
    facingMode: { exact: 'user' }
  }
};

// exact:V -> ideal:V for the named keys; the target value is preserved as a hint
function demote(c, keys) {
  const v = { ...c.video };
  for (const k of keys) {
    if (v[k] && typeof v[k] === 'object' && 'exact' in v[k]) v[k] = { ideal: v[k].exact };
  }
  return { video: v };
}

// Remove keys outright — for terms the device cannot express in any form
function strip(c, keys) {
  const v = { ...c.video };
  for (const k of keys) delete v[k];
  return { video: v };
}

// Rungs are ordered cheapest-concession-first, and each declares what it fixes
const ladder = [
  { touches: ['width', 'height', 'aspectRatio'], apply: (c) => demote(c, ['width', 'height']) },
  { touches: ['frameRate'],                      apply: (c) => demote(c, ['frameRate']) },
  { touches: ['facingMode'],                     apply: (c) => strip(c, ['facingMode']) },
  // Last rung: give up device identity too, take any working camera
  { touches: ['deviceId', 'groupId'],            apply: () => ({ video: true }) }
];

async function acquireCamera() {
  let constraints = base;
  let rung = 0;
  for (let attempt = 0; attempt <= ladder.length; attempt++) {
    try {
      const stream = await navigator.mediaDevices.getUserMedia(constraints);
      const s = stream.getVideoTracks()[0].getSettings();  // requested != applied
      console.info(`acquired at rung ${rung}`, s.width, s.height, s.frameRate);
      localStorage.setItem('camId', s.deviceId);           // re-validate the stored id
      return { stream, settings: s };
    } catch (err) {
      if (err.name !== 'OverconstrainedError') throw err;  // NotAllowedError: do not retry
      // Safari has shipped builds where .constraint is '' — treat that as "unknown"
      const named = err.constraint || '';
      console.warn(`rung ${rung} rejected on "${named || 'unknown'}"`);
      const jump = ladder.findIndex((r, i) => i >= rung && r.touches.includes(named));
      rung = jump === -1 ? rung : jump;                    // skip rungs that fix nothing
      if (rung >= ladder.length) throw err;                // nothing left to loosen
      constraints = ladder[rung].apply(constraints);
      rung += 1;
    }
  }
}

The jump is the whole point: a rejection naming frameRate skips the resolution rung entirely and lands on the frame-rate demotion in one step, so the common 29.97 failure costs one extra device open instead of three. The ladder is a small state machine, and drawing its transitions makes the skip behaviour obvious.

Constraint ladder driven by err.constraint Four ladder states descend from a fully strict constraint set to a bare video true request. Each rejection is labelled with the constraint name that caused it, and a rejection can skip intermediate rungs. Every rung has a success transition onto a shared rail leading to a getSettings readback, and exhausting the ladder reaches a terminal state. Each rejection names a term; the ladder jumps to the rung that loosens it rung 0 — all exact deviceId, 1280x720, 30 fps, user rung 1 — size demoted width/height become ideal rung 2 — rate demoted 29.97 drivers stop rejecting rung 3 — { video: true } device identity surrendered "width" "frameRate" "deviceId" skip resolved track.getSettings() record what you got persist the real deviceId ladder exhausted rethrow, show device help
Four rungs, at most three device opens, and one shared exit through `getSettings()` — the only place that reports what the camera is actually doing.

Reading back is not optional bookkeeping. getSettings() is the sole source of truth for the negotiated width, height, frameRate, deviceId, aspectRatio, and resizeMode, and persisting the returned deviceId is how you keep a stored selection fresh across the identifier churn described in Handling Device Hotplug & Permission Changes. One caveat: settings.frameRate is the capture rate the source was configured for, not the rate reaching the wire — the encoder may be shedding frames under congestion or under a degradationPreference policy, which is measured from outbound-rtp.framesPerSecond as covered in Interpreting getStats() for Congestion Signals.

Reproduction Steps & Debugging Log Patterns

  1. Open a throwaway 320×240 track, call track.getCapabilities(), log the width, height, and frameRate ranges, then stop() it — this is the format list the ladder is negotiating against.
  2. Request frameRate: { exact: 30 } against a camera whose capabilities report max: 29.97. Confirm the rejection and read err.constraint.
  3. Repeat step 2 in Firefox and Chrome with width: { exact: 640 } on a 1280-native camera; Chrome succeeds via crop-and-scale, Firefox rejects.
  4. Clear localStorage, hand-edit the stored camId to a bogus string, and confirm the rejection names deviceId and that the ladder reaches the last rung rather than looping.
  5. On success, diff getSettings() against your requested object and log every field that drifted.

Expected log from a run that hits two rejections:

// probe: caps.width.max=1280 caps.frameRate.max=29.97
// rung 0 rejected on "frameRate"      // jumped straight past the size rung
// rung 2 rejected on "deviceId"       // stored id no longer resolves
// acquired at rung 3  1280 720 29.97  // {video:true} — a different camera
// drift: requested deviceId=4f2a… got 9b7c…, frameRate 30 -> 29.97
Annotated acquisition log A console panel showing five log lines from one acquisition run, with three callout boxes on the right connected to specific lines: the capability probe that predicts the failure, the rung skip caused by the constraint name, and the gap between the captured frame rate and the frame rate reported by getStats. Reading the acquisition log line by line console — one join attempt probe caps: width max 1280 probe caps: frameRate max 29.97 rung 0 rejected on "frameRate" rung 2 rejected on "deviceId" acquired at rung 3: 1280x720 settings.frameRate = 29.97 outbound-rtp fps = 24.1 predicted by the probe exact 30 vs max 29.97 rung 1 was skipped size was never the fault capture is not delivery encoder shed 5.9 fps
The two lines that matter are the probe (which predicts the rejection) and the final pair (which proves capture rate and sent rate are different numbers).

Two properties of this log make it worth emitting in production rather than only in development. The rung number at which a session acquired is a distribution you can chart: if 40% of your users land on rung 3, your base constraint set is wrong for the hardware you actually serve and should be loosened at the source rather than repaired at runtime. And the drift line — requested versus resolved, field by field — is the record that explains a support ticket six weeks later, when a user reports that the camera picker “does not stick” and the real cause is an identifier that rotated between sessions.

A pathological run looks different in a specific way: the same rung number appears repeatedly with the same err.constraint. That means your relaxation did not touch the failing term — usually because the term is nested under an advanced array, which the ladder above never rewrites, or because the constraint name is empty and the jump logic fell through without advancing. If err.constraint is empty on Safari, verify against the device inspector workflow in Debugging WebRTC on Safari and iOS WKWebView before assuming the constraint object is at fault.

Common Implementation Mistakes

FAQ

Should deviceId ever use ideal instead of exact?

Only in a recovery path where opening some camera beats opening none — a hotplug replacement, for example. In the initial acquisition, ideal on deviceId converts a stale identifier into a silent switch to the wrong lens, which users notice and report as a bug. Keep it exact and let the ladder handle the rejection.

Why did the same constraints succeed in Chrome and fail in Firefox?

Chromium synthesises non-native resolutions by cropping and scaling before the constraint is evaluated, so a mandatory width inside the native frame’s bounds is satisfiable. Firefox matches mandatory dimensions against native formats only. Set resizeMode: { ideal: 'crop-and-scale' } explicitly if you rely on the behaviour, and never depend on it for a mandatory value.

Can I avoid the ladder entirely by checking getCapabilities() first?

Partly. A short probe track lets you clamp your request into the advertised ranges and usually removes the rejection, but it costs an extra camera open of 200–400 ms, lights the capture indicator early, and reports ranges that can still be narrowed by another application holding the device. Probe when you need a device picker with accurate ranges; otherwise the ladder is cheaper.

Related: return to Media Constraints & Device Enumeration, then read Enumerating Devices Before Permission Is Granted and degradationPreference: Resolution vs Framerate.