How to Record Video from a Webcam Using WebRTC and RecordRTC in JavaScript
A barcode scanner that reads every still image you give it can still miss on a live camera. The label is turned too far, the hand holding it moves, the autofocus hunts for half a second — and by the time the result box stays empty, the frame that failed is already gone. There is nothing to look at, so there is nothing to fix.
Recording the camera stream is the way out. WebRTC’s getUserMedia hands you a MediaStream, RecordRTC turns it into a video Blob, and that blob can be pushed back through the same decoder the file upload uses. This tutorial builds the clip recorder that ships with the Free Online Barcode Scanner demo: press record, wave the barcode, press stop, and the clip you just made is decoded frame by frame in the same page.
What you’ll build: A record button beside the camera selector that captures the live stream, shows the clip with its duration, resolution and size, and offers three actions — replay it through the barcode decoder, download it, or discard it.
Key Takeaways
getUserMediagives you aMediaStream; RecordRTC wraps it in a recorder you can start and stop at will, andgetBlob()returns the finished video.- Loading the recorder from a CDN on the first click keeps a 78 KB library off the page for visitors who only scan images.
- A recorded blob becomes a
Filein one step, and from there it is an ordinary upload: same preview element, same frame loop, samecapture()call. MediaRecorderwrites WebM without a duration in the header, so the player reportsInfinityand cannot be scrubbed until the page measures the real length.
Common Developer Questions
How do I record video from a webcam in the browser using JavaScript?
Open the camera with navigator.mediaDevices.getUserMedia({ video: true }), attach the returned MediaStream to a <video> element for the preview, and pass the same stream to a recorder. With RecordRTC that is new RecordRTC(stream, { type: 'video' }), then startRecording() and stopRecording(callback); inside the callback getBlob() gives you the video file.
How do I replay a recording through a barcode decoder?
Create a File from the blob, point a <video> element at URL.createObjectURL(file), and on every requestAnimationFrame draw the current frame into a canvas and pass the canvas to CaptureVisionRouter.capture(). That is exactly what an uploaded MP4 goes through, which is the point: the recording is not decoded by a special path, so its result is comparable.
Why does my recorded video show a duration of Infinity?
MediaRecorder finalizes a WebM file without writing the duration into the segment info, and the browser will not seek through a file it cannot measure. Assigning a very large value to currentTime forces the browser to scan to the end and compute the real duration; afterwards you set currentTime back to 0 and the seek bar works normally.
Should I use RecordRTC or the native MediaRecorder API?
MediaRecorder is enough on current Chrome, Edge and Firefox. RecordRTC is worth the extra file when the page has to survive older iOS Safari, where MediaRecorder support arrived late: it picks a canvas-based recorder in that case and keeps the same API on every platform. The demo uses RecordRTC for that reason, and because a single script tag is easier to drop into a page than a capability matrix.
Prerequisites
- A modern browser with WebRTC support (Chrome, Edge, Firefox or Safari).
- A camera, or a fake device for testing — Chrome’s
--use-fake-device-for-media-streamflag is enough. - Basic knowledge of HTML and JavaScript.
- A Dynamsoft Barcode Reader license if you want the replay half to decode anything: get a 30-day free trial license.
Step 1: Open the Camera Stream
The recorder needs a live MediaStream, so start there. List the videoinput devices, let the user pick one, and open it with the constraints the recorder will inherit — the clip is recorded at whatever size the stream runs at.
<label class="label-text"><strong>Select Camera</strong></label>
<select onchange="cameraChanged()" id="camera_source" class="form-select"></select>
async function initCamera() {
const devices = await navigator.mediaDevices.enumerateDevices();
cameraSource.innerHTML = '';
devices.forEach(device => {
if (device.kind === 'videoinput') {
cameraSource.appendChild(new Option(device.label, device.deviceId));
}
});
if (cameraSource.options.length > 0) {
cameraChanged();
}
}
async function cameraChanged() {
const deviceId = cameraSource.value;
const constraints = {
video: {
deviceId: deviceId ? { exact: deviceId } : undefined,
width: { ideal: 1280 },
height: { ideal: 720 }
}
};
try {
stopCamera();
stream = await navigator.mediaDevices.getUserMedia(constraints);
videoElement.srcObject = stream;
updateRecordButton();
} catch (error) {
console.error('Error accessing camera:', error);
updateRecordButton();
}
}
deviceId: { exact: ... } makes a stale device fail loudly instead of silently falling back to the default camera. The ideal width and height are hints: the camera picks the closest mode it supports, and videoElement.videoWidth tells you what you actually got — worth showing next to the clip, because a 640×480 recording explains a lot of failed decodes.
getUserMedia only works in a secure context. https:// and http://localhost qualify; http://192.168.1.20 does not, and the camera is unavailable there.
Step 2: Record the Stream with RecordRTC
RecordRTC is not on the page until someone records. It is fetched the first time the button is pressed, the same way this demo loads its PDF and TIFF decoders:
const RECORDRTC_URL = 'https://cdn.jsdelivr.net/npm/recordrtc@5.6.2/RecordRTC.min.js';
let recordRtcPromise = null;
function loadRecordRTC() {
if (!recordRtcPromise) {
recordRtcPromise = loadScript(RECORDRTC_URL).then(function () {
if (!window.RecordRTC) throw new Error('RecordRTC did not initialise.');
return window.RecordRTC;
}).catch(function (ex) {
recordRtcPromise = null; // let the next click retry
throw ex;
});
}
return recordRtcPromise;
}
Starting a take is three lines once the library is in:
async function startRecording() {
if (recorder || recordingBusy) return;
if (!stream) {
alert('The camera is not running. Pick a camera first.');
return;
}
const RecordRTC = await loadRecordRTC();
recorder = new RecordRTC(stream, { type: 'video' });
recorder.startRecording();
recordStartedAt = Date.now();
recordTimerId = setInterval(function () {
recordTimer.textContent = formatClipTime((Date.now() - recordStartedAt) / 1000);
}, 200);
}
The timer is driven by the wall clock rather than by the frame count, so it stays honest if the browser throttles the tab. A setInterval at 200 ms is enough to make the counter look live without costing anything.
Stopping is where the file appears. stopRecording() takes a callback, and getBlob() is only valid inside it:
function stopRecording() {
const instance = recorder;
const seconds = (Date.now() - recordStartedAt) / 1000;
const width = videoElement.videoWidth;
const height = videoElement.videoHeight;
instance.stopRecording(function () {
const blob = instance.getBlob();
/* Read the blob before tearing the instance down — destroy() clears
the recorder's own copy. The stream is untouched: reset() only drops
the recorder state, so the preview and the next take keep working. */
instance.destroy();
recorder = null;
if (!blob || blob.size === 0) {
alert('The recording came back empty. Check that the camera is still running.');
return;
}
showRecording(blob, seconds, width, height);
});
}
Two details are easy to get wrong. First, destroy() after getBlob(), not before — the recorder drops its reference to the blob when it resets. Second, the wall-clock duration is captured here because it is the only reliable one: the blob’s own metadata has none (Step 4).
In Chrome the resulting file is video/webm;codecs=vp8; Safari produces MP4. Both are handled by naming the file after blob.type:
const extension = blob.type.indexOf('mp4') >= 0 ? 'mp4' : 'webm';
const url = URL.createObjectURL(blob);
recording = {
blob: blob,
url: url,
seconds: seconds,
width: width,
height: height,
file: new File([blob], 'camera-clip.' + extension, { type: blob.type || 'video/' + extension })
};
recordingPreview.src = url;
recordingDownload.href = url;
recordingDownload.setAttribute('download', 'camera-clip.' + extension);
The File is the important object. Names and MIME types matter downstream: the upload handler decides between image, PDF, TIFF, ZIP and video from exactly those two properties, and a browser that reports an empty type for the blob still leaves the extension to go on.

Step 3: Replay the Clip Through the Decoder
Here is the part that makes the recording useful rather than decorative. The clip does not get a private playback path — it is handed to the same function that handles a dropped MP4:
async function scanRecording() {
if (!recording) return;
const file = recording.file;
/* Hand the clip to the upload path instead of writing a second decoder
loop: switching to File mode stops the camera, and loadVideoFile() then
plays the clip frame by frame through the same capture() call an upload
uses. */
dropdown.value = 'file';
await selectChanged();
handleFile(file, true);
}
From there the ordinary video path takes over. The file becomes an object URL on a <video> element, and a requestAnimationFrame loop decodes whatever frame is on screen:
function startFileScanning() {
fileScanning = true;
fileScanResults = [];
const ctx = videoOverlay.getContext('2d');
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
const scanFrame = async () => {
if (!fileScanning || videoFilePlayer.paused || videoFilePlayer.ended) {
if (!videoFilePlayer.ended) fileScanning = false;
return;
}
if (videoFilePlayer.readyState >= 2) {
tempCanvas.width = videoFilePlayer.videoWidth;
tempCanvas.height = videoFilePlayer.videoHeight;
tempCtx.drawImage(videoFilePlayer, 0, 0, tempCanvas.width, tempCanvas.height);
/* No clearRect here: the scan function clears the canvas itself,
once it has the new result. */
await scanVideoFrameDynamsoft(tempCanvas, ctx);
}
if (fileScanning && !videoFilePlayer.ended) {
fileAnimationFrame = requestAnimationFrame(scanFrame);
} else {
fileScanning = false;
}
};
videoFilePlayer.play();
scanFrame();
}
async function scanVideoFrameDynamsoft(canvas, ctx) {
const result = await cvr.capture(canvas, 'ReadBarcodes_Default');
/* Cleared now that the result is in hand, not before the await above.
capture() takes a few animation frames, and a canvas cleared first sits
empty for that whole time — the box then blinks at roughly 15 Hz instead
of staying put. */
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
if (!result.items || result.items.length === 0) return;
let added = false;
for (const item of result.items) {
if (item.type !== Dynamsoft.Core.EnumCapturedResultItemType.CRIT_BARCODE) continue;
/* Drawn on every frame the code is seen. Only the result list below is
deduplicated: skipping the drawing for a code that is already listed
boxes it for exactly one frame of the clip, which looks like no
overlay at all. */
const points = item.location.points;
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
ctx.lineTo(points[1].x, points[1].y);
ctx.lineTo(points[2].x, points[2].y);
ctx.lineTo(points[3].x, points[3].y);
ctx.closePath();
ctx.stroke();
const key = `[${item.formatString || 'Unknown'}] ${item.text}`;
if (!fileScanResults.includes(key)) {
fileScanResults.push(key);
added = true;
}
}
if (added) {
detectionResult.value = fileScanResults.join('\n') + '\n\n';
}
}
Drawing and reporting are two different jobs, and mixing them costs you the overlay. The result list wants one line per unique code, so it deduplicates on [format] text — the same digits can legitimately come back as Code 39 and as Code 128, and collapsing them would hide a wrong-mode decode. The canvas wants the opposite: it is cleared and redrawn every frame, so it has to draw whatever this frame contains, including codes that were already reported.
There is one more thing to line up, and it is invisible until you look at a screenshot: the canvas is stretched over a fixed 4:3 wrapper while the <video> fills that wrapper with object-fit: cover. A 1280×720 clip is cropped left and right, so a box drawn at video coordinates lands somewhere else on screen. Matching the wrapper to the clip’s own aspect removes the crop, and one video pixel becomes one canvas pixel:
videoFilePlayer.onloadedmetadata = async function () {
videoOverlay.width = videoFilePlayer.videoWidth;
videoOverlay.height = videoFilePlayer.videoHeight;
videoFileWrapper.style.paddingBottom =
(videoFilePlayer.videoHeight / videoFilePlayer.videoWidth * 100) + '%';
await fixInfiniteDuration(videoFilePlayer);
startFileScanning();
};
A three-second clip of a Code 39 label reads back as [CODE_39_EXTENDED] CODE39 — the same string the live camera produced, now with a file you can keep, re-run at will, and attach to a bug report.
Step 4: Give the Clip a Duration
Open the recording in a player and the seek bar is dead: video.duration is Infinity, the total time shows as --:--, and dragging does nothing. The cause is in the file, not the player. MediaRecorder streams WebM as it goes and finalizes the file without writing a duration into the segment info; a WebM with no declared length cannot be indexed for seeking, because the browser would have to scan the whole file to know where a timestamp lands.
The workaround is to make the browser scan it once:
function fixInfiniteDuration(video) {
if (video.duration !== Infinity) return Promise.resolve();
return new Promise(function (resolve) {
let settled = false;
const finish = function () {
if (settled) return;
settled = true;
video.removeEventListener('timeupdate', measure);
video.removeEventListener('seeked', rewound);
clearTimeout(guard);
resolve();
};
const measure = function () {
video.removeEventListener('timeupdate', measure);
try {
video.currentTime = 0;
} catch (e) {
finish();
return;
}
video.addEventListener('seeked', rewound);
};
const rewound = function () { finish(); };
const guard = setTimeout(finish, 1500); // never hold a caller hostage
video.addEventListener('timeupdate', measure);
try {
video.currentTime = 1e101; // past the end: forces a measurement
} catch (e) {
finish();
}
});
}
The function resolves once the seek has settled, and that matters for the scanning player: playing while an out-of-range seek is still in flight makes play() abort, the frame loop sees paused === true on its first tick and gives up — a video that silently refuses to scan. So loadVideoFile() waits for the measurement before it starts:
videoFilePlayer.onloadedmetadata = async function () {
videoFileWrapper.style.display = 'block';
videoOverlay.width = videoFilePlayer.videoWidth;
videoOverlay.height = videoFilePlayer.videoHeight;
await fixInfiniteDuration(videoFilePlayer);
startFileScanning();
};
An upload with a proper header reports a finite duration on the first line and is returned untouched, so the workaround costs a normal MP4 nothing.
Common Issues and Edge Cases
- Permission denied or no camera.
getUserMediarejects withNotAllowedErrororNotFoundError. Catch it and say which one happened; the record button stays disabled either way, because recording a stream that does not exist produces an empty blob rather than an error. - The clip is empty. If the tracks were stopped between start and stop — switching camera devices does exactly that — the recorder closes on nothing. Check
blob.sizebefore showing the card and tell the user to try again, instead of offering a 0-byte download. - No audio in the recording. The constraints above ask for video only, so there is no audio track and none is recorded. Add
audio: trueto the constraints if you need it; on a device with no microphone that same call rejects, which is a different failure to handle. - Switching modes mid-take. Leave the camera, and the stream the recorder is holding is torn down. Stop the recorder before stopping the camera — that is what
stopCamera()does first in the demo — or thestopRecordingcallback never fires and the button stays stuck on “Stop”. - Object URLs pile up. Every clip creates one; revoke it when the clip is discarded or replaced, and the browser will not hold the blob alive for the life of the tab. Nothing is uploaded, so closing the tab is the only other cleanup needed.
- Very long takes. Everything is held in memory. A few minutes at 720p is a handful of megabytes; an hour is not, and no browser will thank you.
Try It in the Browser
The recorder is live on the demo page — switch Scan Mode to Live camera, pick a camera and press Start recording:
https://www.dynamsoft.com/codepool/demos/barcode-scanner/
The hosted demo activates a Codepool license bound to dynamsoft.com, so it runs without a key. A self-hosted copy of the same source needs your own Dynamsoft license before it will decode anything.