Recording Video with MediaRecorder for Upload
Open the camera with getUserMedia({ video: { facingMode: "user", width: 1280, height: 720 }, audio: true }), choose the first of video/mp4;codecs=avc1,mp4a, video/webm;codecs=vp9,opus and video/webm;codecs=vp8,opus that MediaRecorder.isTypeSupported accepts, start recording with a timeslice (for example recorder.start(4000)), and upload each dataavailable chunk as it arrives so a two-minute recording is mostly uploaded by the time the user presses stop.
Recording in the page β for video replies, interview answers, product demos, support recordings β avoids the round trip through the phoneβs camera app and lets you show a timer, a question overlay, or a retake button. The catch is that each browser records a different container and codec, the files are not seekable in the form MediaRecorder produces them, and waiting until the end to upload doubles the time users stare at a spinner. This page is part of mobile and camera capture uploads in upload fundamentals and browser APIs. The chunked upload it uses follows resumable upload state machines.
When to use this approach
- The product needs a recording flow inside the page: a prompt on screen, a countdown, a retake, a maximum length.
- Recordings are short to medium β seconds to about ten minutes β so memory and upload time stay reasonable on phones.
- You transcode after upload anyway, so the recorded container and codec do not have to be your delivery format β see post-upload media transcoding.
Prerequisites
- HTTPS and a user gesture to start:
getUserMediarequires a secure context, and autoplaying the preview with sound requires interaction. - A permissions strategy: explain before prompting, and handle
NotAllowedErrorgracefully. - A chunk-accepting endpoint β an append-only upload session keyed by recording ID, with chunks numbered in order.
- Server-side remux or transcode (FFmpeg) to turn MediaRecorder output into a seekable file.
What each browser records
Implementation
const CANDIDATES = [
"video/mp4;codecs=avc1,mp4a", // Safari, newer Chrome
"video/webm;codecs=vp9,opus", // Chrome, Edge
"video/webm;codecs=vp8,opus", // Firefox, older Chrome
"video/webm",
];
export function pickMimeType(): string {
const t = CANDIDATES.find((c) => MediaRecorder.isTypeSupported(c));
if (!t) throw new Error("This browser cannot record video");
return t;
}
export interface Recording { stop(): Promise<{ recordingId: string; bytes: number; mime: string }>; stream: MediaStream }
export async function startRecording(preview: HTMLVideoElement, maxSeconds = 300): Promise<Recording> {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
audio: { echoCancellation: true, noiseSuppression: true },
});
preview.srcObject = stream;
preview.muted = true; // never play the mic back into the room
await preview.play();
const mime = pickMimeType();
const recorder = new MediaRecorder(stream, {
mimeType: mime,
videoBitsPerSecond: 2_500_000, // ~19 MB per minute: plenty for 720p speech
audioBitsPerSecond: 96_000,
});
const create = await fetch("/api/recordings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mime }),
});
if (!create.ok) throw new Error(`could not start upload session: HTTP ${create.status}`);
const { recordingId } = (await create.json()) as { recordingId: string };
let seq = 0, bytes = 0;
let chain: Promise<void> = Promise.resolve(); // strictly ordered chunk uploads
recorder.addEventListener("dataavailable", (e: BlobEvent) => {
if (e.data.size === 0) return;
const n = seq++;
bytes += e.data.size;
chain = chain.then(() => sendChunk(recordingId, n, e.data));
});
const cap = setTimeout(() => { if (recorder.state === "recording") recorder.stop(); }, maxSeconds * 1000);
recorder.start(4000); // a chunk every 4 s
return {
stream,
stop: () => new Promise((resolve, reject) => {
recorder.addEventListener("stop", async () => {
clearTimeout(cap);
stream.getTracks().forEach((t) => t.stop()); // turn the camera light off
try {
await chain; // every chunk acknowledged
const fin = await fetch(`/api/recordings/${recordingId}/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chunks: seq, bytes }),
});
if (!fin.ok) throw new Error(`finalise failed: HTTP ${fin.status}`);
resolve({ recordingId, bytes, mime });
} catch (err) { reject(err); }
}, { once: true });
if (recorder.state !== "inactive") recorder.stop();
}),
};
}
async function sendChunk(recordingId: string, n: number, blob: Blob, attempt = 0): Promise<void> {
const res = await fetch(`/api/recordings/${recordingId}/chunks/${n}`, {
method: "PUT",
body: blob,
headers: { "Content-Type": "application/octet-stream" },
}).catch(() => null);
if (res?.ok) return;
if (attempt >= 5) throw new Error(`chunk ${n} failed after retries`);
await new Promise((r) => setTimeout(r, Math.min(8000, 500 * 2 ** attempt) * Math.random()));
return sendChunk(recordingId, n, blob, attempt + 1);
}
On the server, concatenate chunks in order and remux so the file has a duration and a seek index:
cat chunks/9c1f/*.part > 9c1f.raw
ffmpeg -hide_banner -i 9c1f.raw -c copy -movflags +faststart 9c1f.mp4 # Safari fMP4 β progressive MP4
ffmpeg -hide_banner -i 9c1f.raw -c copy 9c1f.webm # Chrome/Firefox WebM gains Cues + duration
Line-by-line on the parameters that matter
isTypeSupportedin preference order. Recording MP4 where possible saves a transcode for Safari viewers later. The WebM fallbacks cover everything else. Never hard-code one type βnew MediaRecorder(stream, { mimeType })throwsNotSupportedErrorfor an unsupported type.width/heightasideal, notexact. Exact constraints fail withOverconstrainedErroron cameras that cannot produce 1280Γ720;idealgets the closest mode the device offers.preview.muted = true. Playing the live microphone through the speakers causes feedback. The recording still contains audio; only the preview is muted.start(4000)timeslice. Without a timeslice, all data arrives in onedataavailableat stop β a single blob of the whole recording in memory, uploaded only after the user finishes. With four-second slices, memory stays flat and upload overlaps recording.- Chunks only make sense concatenated. Each chunk is a continuation of one byte stream; chunk 7 on its own is not a playable file. Upload them strictly in order (the promise chain) and number them, so the server can reassemble exactly.
videoBitsPerSecond. Browser defaults vary wildly β some record 720p at 8 Mbps. An explicit 2.5 Mbps keeps a five-minute answer under 100 MB with good quality for talking heads.- Stopping tracks. Until every track is stopped, the camera indicator stays on and the device keeps the camera busy β an unsettling signal for users and a battery drain.
Upload overlapping recording
Configuration gotchas
NotAllowedError: Permission denied. The user refused, or the page was not interacted with, or a permissions policy (Permissions-Policy: camera=(), or an iframe without allow="camera; microphone") blocks it. Show a clear explanation and a way to retry; do not loop the prompt.
NotReadableError: Could not start video source. Another app or tab holds the camera (common on Windows). Tell the user to close video-call apps and try again.
Uploaded WebM shows 0:00 and cannot seek. Expected for raw MediaRecorder output: the duration and cue index are never written. Remux on the server as shown; do not try to patch it in the browser.
iOS stops recording when the screen locks. Backgrounding a Safari tab suspends capture. Keep recordings short on iOS, warn users not to lock the screen, and make sure chunk uploads resume when the page returns β the ordered chain retries the pending chunk.
Assembling and validating chunks on the server
The server side of a streamed recording is an append-only log with a finaliser. Each PUT /chunks/:n writes the body to storage under the recording ID and chunk number β to local disk for a single-server setup, or as parts of an S3 multipart upload when recordings can be large. Writes must be idempotent: a retried chunk overwrites the same key, so a lost response followed by a retry leaves exactly one copy.
The completion call carries the chunk count and byte total the client saw. The finaliser checks that every chunk from zero to count β 1 exists and that sizes add up before concatenating; a gap means a chunk upload failed silently, and the right response is a 409 listing the missing numbers so the client can resend them while it still has them in memory. Only after the check does the server remux, probe the result with FFprobe to confirm a playable video and audio stream of plausible duration, and hand the file to the normal processing pipeline.
Treat the recordingβs declared MIME type as a hint. A browser that claims video/webm may produce Matroska with codecs your pipeline does not expect, and a hostile client can send anything. Probe the assembled file, and reject recordings whose actual duration is far beyond the maximum you set in the page β the client-side cap is a courtesy, not a control. The same validation applies to any uploaded video, as in validating video uploads with ffprobe.
Permission and device flow
Verification
- Record ten seconds in Chrome, Firefox and Safari; the server should receive three or four numbered chunks per recording and a completion call with the matching count.
- Remux and check each result:
ffprobe -v error -show_entries format=duration:stream=codec_name,width,height -of compact 9c1f.mp4
# stream|codec_name=h264|width=1280|height=720
# stream|codec_name=aac
# format|duration=10.080000
- Throttle the network to βSlow 3Gβ while recording; chunks should queue and then drain, and the completion call should wait for the last one.
Frequently Asked Questions
Should I transcode in the browser with WebCodecs instead?
Only if bandwidth is the bottleneck and you need a specific bitrate or codec before upload. MediaRecorder already encodes in hardware on most devices; compressing video in the browser with WebCodecs covers when the extra complexity pays off.
How do I let the user preview the recording before sending?
Keep the chunks in memory as well as uploading them, build a Blob from them on stop, and play it with an object URL. Mark the server session as βdraftβ until the user confirms, and delete it if they retake.
Can I record the screen the same way?
Yes β swap getUserMedia for getDisplayMedia. The recorder, chunking and upload code are identical; screen recordings compress very well at low bitrates because most frames barely change.