raw Software

Camera Access in JavaScript

Robert Eisele

In 2010 I published a Flash-to-JavaScript bridge that made a computer's webcam available to JavaScript and <canvas>. Browsers had no native camera API, so the plugin filled a real gap and became one of the most frequently linked projects on this site. Flash is gone, and the bridge is no longer necessary. Modern browsers expose cameras directly through navigator.mediaDevices.getUserMedia().

The native API is shorter than the old bridge, but a reliable implementation still needs more than one call. It must request access at the right time, attach the returned MediaStream, use the camera's actual output dimensions, handle rejected or ignored permission prompts, switch devices without locking the old camera, and stop every track when capture is over.

Camera video track MediaStream getUserMedia() Video preview Canvas frame WebRTC Recorder

Try the Native Camera API

The demo below does nothing until you press Start camera. The browser remains responsible for the permission prompt and visible capture indicator. No frame or recording is uploaded; a captured photo stays in the page's local canvas.

Camera is off.

The Minimal Correct Preview

Camera access should begin with a user action, not automatically when the page loads. A visible button gives the permission prompt context and prevents a surprising request during page navigation. The preview element needs autoplay, muted, and playsinline; together they allow inline playback without routing camera audio back to the speakers or forcing fullscreen video on mobile browsers.

<video id="preview" autoplay muted playsinline></video>
<button id="start-camera" type="button">Start camera</button>
<button id="stop-camera" type="button" disabled>Stop camera</button>
<p id="camera-status" role="status">Camera is off.</p>
const preview = document.getElementById('preview');
const startButton = document.getElementById('start-camera');
const stopButton = document.getElementById('stop-camera');
const status = document.getElementById('camera-status');

let stream = null;

function stopCamera() {
  stream?.getTracks().forEach((track) => track.stop());
  stream = null;
  preview.srcObject = null;
  startButton.disabled = false;
  stopButton.disabled = true;
  status.textContent = 'Camera is off.';
}

async function startCamera() {
  stopCamera();
  startButton.disabled = true;
  status.textContent = 'Waiting for permission...';

  try {
    stream = await navigator.mediaDevices.getUserMedia({
      audio: false,
      video: {
        width: { ideal: 1280 },
        height: { ideal: 720 },
        facingMode: { ideal: 'user' },
      },
    });

    preview.srcObject = stream;
    await preview.play();
    stopButton.disabled = false;
    status.textContent = 'Camera active.';
  } catch (error) {
    stopCamera();
    status.textContent = `${error.name}: ${error.message}`;
  }
}

startButton.addEventListener('click', startCamera);
stopButton.addEventListener('click', stopCamera);
window.addEventListener('pagehide', stopCamera);

getUserMedia() returns a promise for a MediaStream. The promise may also remain pending when the user ignores the prompt, so the interface should continue to show a waiting state rather than assuming that every request quickly succeeds or fails.

Security, Permission, and Embedding

Camera access is available only in a secure context: use HTTPS in production or localhost during local development. In an insecure context, navigator.mediaDevices may be undefined. Browsers always control the permission prompt and display their own capture indicator while a camera is in use.

Request only what the feature needs. A still-photo tool should use audio: false; asking for a microphone at the same time creates a broader permission request without providing any benefit. Camera access from a cross-origin iframe also requires permission from the top-level page:

Permissions-Policy: camera=(self "https://camera.example")
<iframe src="https://camera.example/capture" allow="camera"></iframe>

Sandboxed frames need an origin as well. An iframe without the necessary policy usually receives NotAllowedError without ever showing a prompt.

Constraints Are Preferences Unless Marked Exact

The browser selects a camera and configures its track from the constraints. Plain values and ideal express preferences. min, max, and exact are mandatory and can reject the whole request with OverconstrainedError.

ConstraintTypical useBehavior
width, heightPrefer a capture resolutionMay be adapted by the browser
frameRateLimit CPU or bandwidthUse max only when the limit is important
facingMode: "user"Front-facing cameraPreference unless wrapped in exact
facingMode: "environment"Rear-facing cameraCommon for documents and barcodes
deviceIdSelect an enumerated cameraUse exact only for an explicit choice

Do not confuse CSS dimensions with capture dimensions. A video can be displayed at 640 pixels wide while the underlying track delivers 1920 pixels. After metadata has loaded, video.videoWidth and video.videoHeight report the frame dimensions. The active track exposes the browser's final decision:

const [track] = stream.getVideoTracks();
console.table(track.getSettings());

await track.applyConstraints({
  frameRate: { ideal: 15, max: 24 },
});

Capture a Full-Resolution Frame with Canvas

Drawing the video into a canvas captures the currently displayed frame and works across the broadest set of modern browsers. Size the canvas from the stream, not from the element's CSS box:

function captureFrame(video, canvas) {
  if (!video.videoWidth || !video.videoHeight) {
    throw new Error('The camera has not produced a frame yet.');
  }

  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;

  const context = canvas.getContext('2d');
  context.drawImage(video, 0, 0, canvas.width, canvas.height);
}

Convert the result asynchronously when it needs to be uploaded or downloaded:

const blob = await new Promise((resolve, reject) => {
  canvas.toBlob((result) => {
    if (result) resolve(result);
    else reject(new Error('Could not encode the captured frame.'));
  }, 'image/jpeg', 0.92);
});

const formData = new FormData();
formData.append('photo', blob, 'capture.jpg');
await fetch('/upload', { method: 'POST', body: formData });

The canvas path captures the video frame after browser processing. Where supported, ImageCapture.takePhoto() can ask the camera track for a photographic exposure and may expose additional device capabilities. Treat ImageCapture as progressive enhancement because its support is less uniform than canvas capture.

List and Switch Cameras

enumerateDevices() returns available media devices. Before permission, labels and non-default devices may be hidden to limit fingerprinting. A practical selector therefore starts one camera, then enumerates the now authorized inputs:

async function listCameras() {
  const devices = await navigator.mediaDevices.enumerateDevices();
  return devices.filter((device) => device.kind === 'videoinput');
}

async function switchCamera(deviceId) {
  stream?.getTracks().forEach((track) => track.stop());

  stream = await navigator.mediaDevices.getUserMedia({
    audio: false,
    video: { deviceId: { exact: deviceId } },
  });

  preview.srcObject = stream;
  await preview.play();
}

Stop the old tracks before requesting another camera, especially when changing between front and rear cameras on a phone. Listen for navigator.mediaDevices' devicechange event and rebuild the selector when a USB camera is connected or removed. Device IDs are privacy-scoped identifiers, not permanent hardware serial numbers.

Record Video with MediaRecorder

MediaRecorder consumes the same stream used by the preview. Container and codec support differ between browsers, so select a supported MIME type instead of always forcing WebM or MP4:

const candidates = [
  'video/webm;codecs=vp9,opus',
  'video/webm;codecs=vp8,opus',
  'video/mp4',
];

const mimeType = candidates.find((type) => MediaRecorder.isTypeSupported(type));
const chunks = [];
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);

recorder.addEventListener('dataavailable', (event) => {
  if (event.data.size) chunks.push(event.data);
});

recorder.addEventListener('stop', () => {
  const recording = new Blob(chunks, { type: recorder.mimeType });
  const url = URL.createObjectURL(recording);
  playback.src = url;
});

recorder.start(1000);
// Later: recorder.stop();

A timeslice produces periodic chunks, but it is not a precise clock. Use elapsed wall time for duration displays. Revoke old object URLs with URL.revokeObjectURL() when replacing or removing recordings.

Handle Failures as Product States

ErrorLikely causeUseful response
NotAllowedErrorPermission denied, insecure context, or policy blockExplain HTTPS and browser/site permission controls
NotFoundErrorNo camera satisfies the requestOffer a less restrictive request
NotReadableErrorCamera busy or operating-system failureAsk the user to close other camera applications
OverconstrainedErrorA mandatory constraint cannot be metReport error.constraint and retry with preferences
AbortErrorOpening the device was interruptedAllow an explicit retry

Do not treat denial as an exceptional crash. A camera can disappear, another application can take it, the operating system can revoke access, and the track can end independently of the page. Listen for the track's ended event, disable capture controls when no live track exists, and keep a normal file-upload fallback where possible.

Release the Camera Completely

Clearing video.srcObject does not stop hardware capture. The stream owns one or more tracks, and every track must be stopped. This turns off the browser's active-capture indicator and releases the camera for other pages and applications:

function releaseStream(stream, video) {
  stream?.getTracks().forEach((track) => track.stop());
  video.srcObject = null;
}

Call cleanup when the user presses Stop, when changing devices, after a one-shot capture if no preview is needed, and on page lifecycle events such as pagehide. A page that keeps an unused live track is not merely wasteful; it leaves the camera indicator active and erodes trust.

Where the Stream Can Go Next

The old Flash bridge existed because the browser could not cross the hardware boundary itself. Today the browser provides that boundary, including permission, indicators, device selection, and track lifecycle. The application should build on those guarantees rather than trying to conceal or bypass them.

References