../

Media devices

Camera, microphone and screen capture with navigator.mediaDevices, the MediaStream and MediaStreamTrack objects they return, recording with MediaRecorder, and still photos. Sending streams to peers is in WebRTC, frame-level encoding in WebCodecs.

Support & TypeScript

APIStatus (MDN / Baseline)
getUserMedia(), MediaStream, MediaStreamTrackBaseline widely available (since 2017)
enumerateDevices(), devicechangewidely available; Firefox 116 switched to the spec's privacy rules
MediaRecorder, isTypeSupported()Baseline widely available (since 2021)
track.getCapabilities()all engines (Firefox 132+)
navigator.permissions.query({ name: "camera" })all engines (Firefox 132+, Safari 16+)
getDisplayMedia()not Baseline: desktop Chrome, Edge, Firefox, Safari; no mobile browser
ImageCapturenot Baseline: Chromium; Safari 18.4 (takePhoto), 26 (grabFrame); Firefox behind a flag
HTMLMediaElement.setSinkId() (output device)Chrome, Firefox 116, Safari 18.4; not Chrome Android
CaptureController, preferCurrentTab, systemAudioChromium only

Everything here needs a secure context: HTTPS, localhost or file:. On plain HTTP navigator.mediaDevices is undefined.

  • lib.dom types the core: MediaStreamConstraints, MediaTrackConstraints, MediaDeviceInfo, MediaRecorderOptions, BlobEvent, OverconstrainedError.
  • DisplayMediaStreamOptions only has audio and video; Chromium extras such as selfBrowserSurface need an extended type (see Screen capture).
  • MediaTrackConstraintSet lacks Image Capture constraints (zoom, torch, focusMode) even though MediaTrackSettings has some; widen the type when you use them.
  • TS 5.9 ImageCapture has no grabFrame(); add it with an interface merge.
  • Errors come back as DOMException (a subclass of Error); switch on err.name.

getUserMedia & constraints

const stream = await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: true, noiseSuppression: true },
  video: {
    width: { ideal: 1280 },
    height: { ideal: 720 },
    frameRate: { ideal: 30, max: 60 },
    facingMode: "user", // "environment" = back camera
  },
});
Constraint formMeaningIf unmet
width: 1280bare value = idealbest effort
{ ideal: 1280 }aim for it, closest winsbest effort
{ min: 640, max: 1920 }hard rangeOverconstrainedError
{ exact: id }this value onlyOverconstrainedError
advanced: [{...}, {...}]try each set in order, skip the ones that can't be metskipped
ConstraintKindValues / notes
deviceIdbothfrom enumerateDevices(); use { exact: id } to force a device
groupIdbothsame physical device (webcam + its mic)
width, heightvideopixels; the camera picks the nearest native mode
aspectRatiovideoe.g. 16 / 9
frameRatevideoframes per second
facingModevideo"user", "environment", "left", "right"
resizeModevideo"none" (native modes only) or "crop-and-scale"; not in TS types
echoCancellationaudiodefault true for mics
noiseSuppressionaudiodefault true in most browsers
autoGainControlaudiodefault true; turn off for music
sampleRate, sampleSize, channelCountaudioe.g. 48000, 16, 1 or 2
displaySurfacescreen"browser", "window", "monitor" (a hint for getDisplayMedia)
// which constraints does this browser understand at all?
const supported =
  navigator.mediaDevices.getSupportedConstraints();
if (supported.frameRate) {
  /* safe to constrain frameRate */
}

Permissions & secure context

LayerHowNotes
Secure contextHTTPS or localhostotherwise navigator.mediaDevices is undefined
Permissions PolicyPermissions-Policy: camera=(self), microphone=(self)blocks or allows the whole document
iframes<iframe allow="camera; microphone; display-capture">cross-origin frames get nothing without it
User promptfirst getUserMedia() per origingrants can be one-time (Chrome "Allow this time"), per session, or remembered
Query statenavigator.permissions.query({ name: "camera" })"granted", "denied", "prompt"; has a change event
Indicatorbrowser tab / OS lightshown while any track is live; track.stop() clears it
async function cameraPermission(): Promise<PermissionState> {
  try {
    const s = await navigator.permissions.query({
      name: "camera",
    });
    return s.state;
  } catch {
    return "prompt"; // query not supported: just ask
  }
}

There is no API to request a permission without capturing: call getUserMedia() from a click and explain first, because a dismissed prompt can turn into a sticky block.

Devices

const md = navigator.mediaDevices;
const devices = await md.enumerateDevices();
const cams = devices.filter((d) => d.kind === "videoinput");
// before any grant: labels are "", often one entry per kind
 
md.addEventListener("devicechange", () => {
  // plugged / unplugged: re-enumerate and refresh the UI
});
MediaDeviceInfoNotes
deviceIdstable per origin until site data is cleared; Chromium adds "default"/"communications" audio aliases
groupIdsame value for a camera and its built-in mic
kind"videoinput", "audioinput", "audiooutput"
labelhuman name; empty until the user granted access in this origin
  • Enumerate after a successful getUserMedia() to get real labels and the full list.
  • Switch device by stopping the old track and capturing with deviceId: { exact: id }.
  • Pick a speaker with audioElement.setSinkId(deviceId) where supported.

Screen capture

getDisplayMedia() must run inside a user gesture (transient activation); the browser shows its own picker and never remembers the choice.

type DisplayOptions = DisplayMediaStreamOptions & {
  // Chromium-only hints, not in lib.dom yet
  selfBrowserSurface?: "include" | "exclude";
  systemAudio?: "include" | "exclude";
  surfaceSwitching?: "include" | "exclude";
  monitorTypeSurfaces?: "include" | "exclude";
  preferCurrentTab?: boolean;
};
 
const options: DisplayOptions = {
  video: { displaySurface: "monitor", frameRate: 30 },
  audio: true, // tab audio in Chromium; ignored elsewhere
  selfBrowserSurface: "exclude",
};
const screen =
  await navigator.mediaDevices.getDisplayMedia(options);
 
const [track] = screen.getVideoTracks();
track!.contentHint = "detail"; // text/slides: keep sharpness
track!.addEventListener("ended", () => {
  // user clicked the browser's "Stop sharing"
});
contentHintEncoder favors
"motion"frame rate (video, games)
"detail" / "text"resolution and sharpness (slides, code)
"speech" / "music"audio tracks: voice processing vs fidelity

Streams & tracks

A MediaStream is a bag of MediaStreamTracks; the track is what holds the device.

MediaStreamDoes
getTracks(), getVideoTracks(), getAudioTracks()list tracks
addTrack(t), removeTrack(t)compose streams (e.g. screen video + mic audio)
clone()new stream with cloned tracks
activetrue while any track is live
new MediaStream(tracks)build one from existing tracks
MediaStreamTrackDoes
stop()releases the device; readyState becomes "ended", for good
enabled = falsesends black frames / silence; device stays on (a "mute" button)
muted + mute/unmute eventssource paused by the system (OS, another app, backgrounded tab)
readyState"live" or "ended"; ended event when the device vanishes
getSettings()actual values: width, height, frameRate, deviceId, …
getCapabilities()ranges the device supports
getConstraints()what you last asked for
applyConstraints(c)change resolution, frame rate, torch… without re-prompting
clone()independent copy; each clone must be stopped
label, kind, iddescription
const [cam] = stream.getVideoTracks();
if (cam) {
  const caps = cam.getCapabilities();
  const maxW = caps.width?.max ?? 1280;
  await cam.applyConstraints({ width: { ideal: maxW } });
  const { width, height, frameRate } = cam.getSettings();
  console.log(`${width}x${height}@${frameRate}`);
}
 
// always release devices when done
function stopAll(s: MediaStream) {
  for (const t of s.getTracks()) t.stop();
}

MediaRecorder

Records a stream into a compressed container, delivered as Blob chunks.

const mimeType = [
  "video/webm;codecs=vp9,opus",
  "video/webm;codecs=vp8,opus",
  "video/mp4;codecs=avc1,mp4a.40.2",
  "video/webm",
  "video/mp4",
].find((t) => MediaRecorder.isTypeSupported(t));
 
const rec = new MediaRecorder(stream, {
  mimeType, // undefined = browser default
  videoBitsPerSecond: 2_500_000,
});
const chunks: Blob[] = [];
rec.addEventListener("dataavailable", (e) => {
  if (e.data.size > 0) chunks.push(e.data);
});
rec.addEventListener("stop", () => {
  const blob = new Blob(chunks, { type: rec.mimeType });
});
rec.start(1000); // emit a chunk every second
MemberNotes
start(timeslice?)without timeslice you get one blob at stop()
stop()fires a last dataavailable, then stop
pause(), resume(), requestData()requestData flushes a chunk now
state"inactive", "recording", "paused"
mimeTypewhat is actually being produced
isTypeSupported(t)static; always probe, never assume
error evente.g. a track was added or removed mid-recording
BrowserRecords
Chrome, EdgeWebM (VP8, VP9, AV1, H.264; Opus), MP4 in recent versions
FirefoxWebM (VP8, Opus), Ogg (Opus)
Safari 14.1–18.3MP4 only (H.264, AAC)
Safari 18.4+MP4 and WebM (VP8/VP9, Opus)

Photos

Draw the current video frame to a canvas; it works everywhere and gives you the preview's size.

async function snapshot(
  video: HTMLVideoElement,
): Promise<Blob> {
  const canvas = document.createElement("canvas");
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("2d context unavailable");
  ctx.drawImage(video, 0, 0);
  return new Promise((resolve, reject) =>
    canvas.toBlob(
      (b) => (b ? resolve(b) : reject(new Error("toBlob"))),
      "image/jpeg",
      0.9,
    ),
  );
}

ImageCapture

takePhoto() asks the camera for a full-resolution still (flash, red-eye); grabFrame() returns the live frame as an ImageBitmap. Feature-detect and fall back to the canvas path.

declare global {
  interface ImageCapture {
    grabFrame(): Promise<ImageBitmap>; // missing in TS 5.9
  }
}
 
async function takePhoto(track: MediaStreamTrack) {
  if (!("ImageCapture" in window)) return null;
  const capture = new ImageCapture(track);
  const caps = await capture.getPhotoCapabilities();
  return capture.takePhoto({
    imageWidth: caps.imageWidth?.max,
  }); // Blob, often JPEG
}
 
export {};

Audio levels

Route the mic into Web Audio and read an AnalyserNode; details in Web Audio.

const ctx = new AudioContext(); // resume() it on a click
const source = ctx.createMediaStreamSource(stream);
const analyzer = ctx.createAnalyser();
analyzer.fftSize = 1024;
source.connect(analyzer); // not to ctx.destination: no echo
 
const buf = new Float32Array(analyzer.fftSize);
function rmsDb(): number {
  analyzer.getFloatTimeDomainData(buf);
  let sum = 0;
  for (const v of buf) sum += v * v;
  const rms = Math.sqrt(sum / buf.length);
  return 20 * Math.log10(rms || 1e-8); // dBFS, 0 = max
}

Errors

err.nameFromCauseWhat to show
NotAllowedErrorbothuser or policy denied; also insecure context in some browsershow to re-enable in site settings
NotFoundErrorbothno device of that kind (or none match)"No camera found"
NotReadableErrorbothhardware/OS error, device busy in another app (Windows)"Close other apps using the camera"
OverconstrainedErrorbothexact/min/max can't be met; see err.constraintretry with ideal
AbortErrorbothsomething else stopped the device from being usedretry
SecurityErrorgetUserMediamedia disabled on the document
InvalidStateErrorbothdocument not fully active; getDisplayMedia without a user gesturecall from a click
TypeErrorbothempty constraints, video: false on screen capture, min/exact therefix the call
function mediaErrorMessage(err: unknown): string {
  const name = err instanceof Error ? err.name : "";
  switch (name) {
    case "NotAllowedError":
      return "Permission denied. Allow it in site settings.";
    case "NotFoundError":
      return "No matching device found.";
    case "NotReadableError":
      return "The device is in use by another app.";
    case "OverconstrainedError":
      return "The device can't meet those settings.";
    default:
      return "Could not start capture.";
  }
}

Pitfalls & iOS Safari

  • Always stop tracks. Unmount, route change, hang-up: stream.getTracks().forEach(t => t.stop()). Dropping the reference or clearing srcObject leaves the camera light on.
  • React: capture in useEffect and stop in its cleanup; Strict Mode runs it twice in dev, so every capture must be undone (React hooks).
  • Autoplay: preview <video> elements need autoplay, playsinline and muted.
  • Clones keep the device alive; stop each clone as well.
  • enabled = false is not privacy: the device stays on; use stop() to release it.
  • Labels are empty until the user grants access; enumerate after getUserMedia().
  • Exact device IDs: a stored deviceId can vanish; catch OverconstrainedError and fall back.
iOS / iPadOS Safari quirkWorkaround
<video> goes fullscreen or stays blackplaysinline + muted + autoplay
A second camera capture mutes or ends the first trackstop the old track before switching cameras
Tracks get muted when the app is backgrounded or a call startslisten for mute/unmute, show state
Grants may not persist across reloadsexpect the prompt again; don't loop on it
No getDisplayMedia()hide screen share on mobile
MediaRecorder makes MP4 before 18.4pick the type with isTypeSupported()
AudioContext starts suspendedcreate or resume() it in a tap handler
In-app browsers (WKWebView)capture only works if the host app allows it

Recipes

Camera preview in a video element

Start the front camera in a <video> and get back a function that releases it.

preview.ts
export async function startPreview(
  video: HTMLVideoElement,
  deviceId?: string,
): Promise<() => void> {
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: false,
    video: deviceId
      ? { deviceId: { exact: deviceId } }
      : { facingMode: "user", width: { ideal: 1280 } },
  });
  Object.assign(video, { muted: true, playsInline: true });
  video.srcObject = stream;
  await video.play();
  return () => {
    for (const t of stream.getTracks()) t.stop();
    video.srcObject = null;
  };
}

Device picker

Fill a <select> with cameras, keep it fresh on hot-plug, and switch on change.

import { startPreview } from "./preview";
 
export async function cameraPicker(
  select: HTMLSelectElement,
  video: HTMLVideoElement,
) {
  const md = navigator.mediaDevices;
  let stop = await startPreview(video); // grant => labels
  const fill = async () => {
    const cams = (await md.enumerateDevices())
      .filter((d) => d.kind === "videoinput");
    select.replaceChildren(...cams.map((d, i) =>
      new Option(d.label || `Camera ${i + 1}`, d.deviceId)));
  };
  await fill();
  md.addEventListener("devicechange", fill);
  select.addEventListener("change", async () => {
    stop();
    stop = await startPreview(video, select.value);
  });
}

Record the screen to a WebM download

Screen plus tab/system audio where available, saved when the user stops sharing.

export async function recordScreen() {
  const md = navigator.mediaDevices;
  const stream = await md.getDisplayMedia({ audio: true });
  const type = MediaRecorder.isTypeSupported("video/webm")
    ? "video/webm" : "video/mp4"; // older Safari
  const rec = new MediaRecorder(stream, { mimeType: type });
  const chunks: Blob[] = [];
  rec.ondataavailable = (e) => chunks.push(e.data);
  rec.onstop = () => {
    const blob = new Blob(chunks, { type });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `screen-${Date.now()}.${type.slice(6)}`;
    a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 10_000);
  };
  const [video] = stream.getVideoTracks(); // "Stop sharing"
  video?.addEventListener("ended", () => {
    if (rec.state !== "inactive") rec.stop();
    for (const t of stream.getTracks()) t.stop();
  });
  rec.start(1000);
}

Snapshot photo

Grab a still from a live preview as a PNG or JPEG File, ready for upload or FormData.

export async function capturePhoto(
  video: HTMLVideoElement,
  type: "image/jpeg" | "image/png" = "image/jpeg",
): Promise<File> {
  const bitmap = await createImageBitmap(video);
  const { width, height } = bitmap;
  const canvas = new OffscreenCanvas(width, height);
  canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
  bitmap.close();
  const blob = await canvas.convertToBlob({
    type,
    quality: 0.9,
  });
  const ext = type === "image/png" ? "png" : "jpg";
  const name = `photo-${Date.now()}.${ext}`;
  return new File([blob], name, { type });
}

Mic level meter

A 0–1 level for a VU bar, updated every animation frame; call the returned function to stop.

export async function micMeter(cb: (v: number) => void) {
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: { autoGainControl: false },
  });
  const ctx = new AudioContext();
  await ctx.resume(); // call micMeter from a click
  const analyzer = ctx.createAnalyser();
  ctx.createMediaStreamSource(stream).connect(analyzer);
  const buf = new Float32Array(analyzer.fftSize);
  let raf = 0;
  const tick = () => {
    analyzer.getFloatTimeDomainData(buf);
    const rms = Math.hypot(...buf) / Math.sqrt(buf.length);
    const db = 20 * Math.log10(rms || 1e-8);
    cb(Math.min(1, Math.max(0, (db + 60) / 60))); // -60..0
    raf = requestAnimationFrame(tick);
  };
  tick();
  return () => {
    cancelAnimationFrame(raf);
    for (const t of stream.getTracks()) t.stop();
    void ctx.close();
  };
}

References