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.
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.
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
- Open a throwaway 320×240 track, call
track.getCapabilities(), log thewidth,height, andframeRateranges, thenstop()it — this is the format list the ladder is negotiating against. - Request
frameRate: { exact: 30 }against a camera whose capabilities reportmax: 29.97. Confirm the rejection and readerr.constraint. - Repeat step 2 in Firefox and Chrome with
width: { exact: 640 }on a 1280-native camera; Chrome succeeds viacrop-and-scale, Firefox rejects. - Clear
localStorage, hand-edit the storedcamIdto a bogus string, and confirm the rejection namesdeviceIdand that the ladder reaches the last rung rather than looping. - 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
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
- Marking
widthandheightmandatory together. Twoexactdimensions must be satisfiable by one format simultaneously; a camera offering 1280×720 and 640×360 rejects1280×768even though both numbers look reasonable in isolation. - Treating
advancedas a safe place for strict values. Entries in theadvancedarray are applied as all-or-nothing groups that never reject the call — they are silently skipped instead, so a value you believed was enforced simply is not, andgetSettings()is the only way to notice. - Relaxing the wrong term. Blind resolution ladders burn a 100–400 ms device open per rung on properties that were never named in
err.constraint. Branch on the name. - Persisting
deviceIdwithout writing back the resolved one. StoregetSettings().deviceIdafter every successful acquisition, not the value you requested; the two differ the moment a fallback rung fires. - Testing only on the developer’s machine. A 1080p external webcam hides every failure mode in this guide. Test against a 720p laptop sensor and a virtual camera, which typically advertises exactly one format and rejects almost any mandatory term.
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.