JavaScript getUserMedia Table
| Piece | What it does | Field note |
|---|---|---|
getUserMedia({ video: true }) | Ask for camera | Prompts once per origin - the permission dialog is NOT scriptable |
{ video: { width: 1280 } } | Constrain the stream | ideal by default; exact: for must-haves - OverconstrainedError when impossible |
srcObject = stream | Preview the feed | Set the MEDIA STREAM on video, not .src - the classic blank-preview bug |
enumerateDevices() | List cameras/mics | Labels empty BEFORE permission - privacy by omission |
stop() on tracks | Release the hardware | Camera light stays on until EVERY track stops - cleanup = permission karma |
MediaRecorder | Record a stream | WebM/MP4 chunks - the screen/webcam recording pipeline |
getDisplayMedia | Screen capture | Separate API, same shape - picks the screen/tab prompt |
HTTPS + permission | The two gates | Insecure contexts get nothing; deny = NotFoundError-style failures to design for |
getUserMedia is the camera and microphone gateway: one call, one permission prompt that is never scriptable (the user decides, every time it matters), and a MediaStream you attach to a video element with srcObject. HTTPS is the entry fee - insecure contexts get no camera API at all.
Bottom line: constraints are IDEAL by default - ask for 1280px and the browser gives the closest it can; only exact: turns the wish into a requirement, at the price of OverconstrainedError when the hardware cannot comply. And the camera light stays on until EVERY track's stop() runs - hardware cleanup is your job.
The honest part: privacy runs through the whole API. Device labels stay empty until permission is granted (enumerateDevices works before, but anonymized), the permission prompt cannot be triggered programmatically, and a denied permission fails with specific error names (NotAllowedError, NotFoundError) that your UI should handle as distinct stories.
How to use
- Attach the preview correctly: videoEl.srcObject = stream - NOT videoEl.src = stream, which stringifies and yields the classic blank-preview bug.
- Clean up on every exit path: stream.getTracks().forEach(t => t.stop()) in the close handler, the error path AND before re-requesting - the camera light is the truth.
- Record the stream: new MediaRecorder(stream) produces WebM/MP4 chunks - the webcam and screen-recording pipeline is this plus a download link.
Frequently asked questions
Why are my device labels empty strings before permission?
Privacy by omission. enumerateDevices() deliberately works WITHOUT permission (so a pre-permission UI can count cameras and show a picker), but labels - 'FaceTime HD Camera' - would leak fingerprints about the user's hardware, so they are blank strings until getUserMedia has been granted at least once. The working pre-permission UX: list 'Camera 1, Camera 2' with generic names, then after the first grant, re-enumerate to bind friendly labels to deviceIds. Note the deviceId itself is also session-scoped until permission - persistent IDs require the permissions API's persistence, another layer of the same privacy contract.
What is the difference between ideal and exact constraints?
Wish versus demand. { width: 1280 } states an ideal - the browser picks the closest supported mode and never fails (a 640px webcam gives you 640px, silently). { width: { exact: 1280 } } makes it a requirement - unsupported means OverconstrainedError, not degradation, which is what barcode scanning or fixed-resolution pipelines need. The same grammar covers frameRate, facingMode (user/environment - ideal 'environment' prefers the rear camera without failing on desktops), and advanced constraints lists that cascade. The design rule: exact only when correctness depends on it; ideal everything else, because ideal failures are silent successes at lower quality.
Why does the camera indicator stay lit after my page 'closes' the camera?
Because stopping is per-track, explicit, and easy to forget. Closing the modal, navigating away, or even setting srcObject = null does not stop the underlying tracks - the MediaStream keeps its video track live until stop() is called on it, and the OS camera light reflects the hardware state, not your UI state. The discipline: every code path that acquires a stream (success, error, unmount, beforeunload for long-lived pages) ends with stream.getTracks().forEach(track => track.stop()). Track-ended events also matter: some platforms end tracks externally (another app takes the camera) - handle the 'ended' event or your UI shows a frozen frame believing it is live.
How does getDisplayMedia relate to getUserMedia?
Same stream shape, different permission story. getDisplayMedia captures a screen, window or browser tab (the picker is browser UI - the user chooses WHAT, not just WHETHER), and it exists precisely because screen capture is a bigger privacy step than camera access. Practical differences: displaySurface hints at what to prefer but the user's choice wins, audio capture from a tab is possible where supported, and there is no enumerateDevices equivalent - you cannot list screens. The pairing is also the recording pipeline: either call feeds MediaRecorder equally, so 'record my screen with my camera inset' is two streams composed (compositing is the hard part, the capture is not).