getUserMedia in Production: Constraints, Permission UX, and the Device Quirks Nobody Documents

The real-world guide to camera capture in the browser — constraint negotiation, permission flows that don't scare users, and the hardware quirks that break on specific devices.

getUserMedia() looks like a five-line demo. In production it’s a negotiation layer between your constraints, the browser’s policy, the OS’s permissions, and whatever the hardware actually supports — and every layer has edge cases that bite.

The Constraint API Is a Negotiation, Not a Command

// Constraints are hints, not orders. Browser picks closest match.
const stream = await navigator.mediaDevices.getUserMedia({
  video: {
    width: { ideal: 1280, max: 1920 },
    height: { ideal: 720 },
    frameRate: { ideal: 30, max: 30 },
    facingMode: 'user'
  },
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  }
});

Use ideal for preferred values and exact/max only when failure is acceptable — an exact constraint on an unsupported resolution throws OverconstrainedError instead of falling back.

The Permission UX That Doesn’t Scare Users

PatternUser ReactionRecommendation
Request on page load“Why does this site want my camera?” — deny, leaveDon’t
Request on first use with context“OK, it’s asking because I clicked record”Do
Graceful deny + re-prompt pathUser can reconsider without a page refreshRequired

Always explain why before the browser prompt appears — a one-line “we need your camera to record” converts dramatically better than a cold permission request.

The Quirks That Bite

  1. iOS Safari requires a user gesture — getUserMedia called from a non-user-initiated context throws silently on older versions. Trigger from a click handler.
  2. Android Chrome on low-end devices lies about supported resolutions — getCapabilities() reports 1080p but the camera delivers 720p with heavy frame drops. Check track.getSettings() after capture starts.
  3. Firefox’s video constraints persist differently — Firefox remembers camera choices per-origin more aggressively; a denied permission may need permissions.revoke() handling.
  4. Privacy indicators stay on — the OS-level “camera in use” LED doesn’t clear until all tracks are stopped, not just paused. Call track.stop() not just videoElement.srcObject = null.

“The demo works on your MacBook. Production means the $80 Android with a bottom-mounted selfie camera, the corporate laptop with a privacy shutter, and the user who clicks ‘Block’ by reflex.”

Our device-quirk test matrix, permission state machines, and the retry-UX patterns are in the getUserMedia production guide.