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.
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.
| Constraint | Typical use | Behavior |
|---|---|---|
width, height | Prefer a capture resolution | May be adapted by the browser |
frameRate | Limit CPU or bandwidth | Use max only when the limit is important |
facingMode: "user" | Front-facing camera | Preference unless wrapped in exact |
facingMode: "environment" | Rear-facing camera | Common for documents and barcodes |
deviceId | Select an enumerated camera | Use 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
| Error | Likely cause | Useful response |
|---|---|---|
NotAllowedError | Permission denied, insecure context, or policy block | Explain HTTPS and browser/site permission controls |
NotFoundError | No camera satisfies the request | Offer a less restrictive request |
NotReadableError | Camera busy or operating-system failure | Ask the user to close other camera applications |
OverconstrainedError | A mandatory constraint cannot be met | Report error.constraint and retry with preferences |
AbortError | Opening the device was interrupted | Allow 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
- Canvas: snapshots, local filters, OCR preparation, barcode scanning, and computer-vision input.
- MediaRecorder: local video or audio recording as encoded chunks.
- WebRTC: real-time peer-to-peer conferencing and remote assistance.
- WebCodecs: lower-level frame and codec pipelines where browser support and complexity are justified.
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
- [MediaCapture]World Wide Web Consortium. Media Capture and Streams.
- [MediaRecording]World Wide Web Consortium. MediaStream Recording.
- [ImageCapture]World Wide Web Consortium. MediaStream Image Capture.