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

  1. HTTPS and a user gesture to start: getUserMedia requires a secure context, and autoplaying the preview with sound requires interaction.
  2. A permissions strategy: explain before prompting, and handle NotAllowedError gracefully.
  3. A chunk-accepting endpoint β€” an append-only upload session keyed by recording ID, with chunks numbered in order.
  4. Server-side remux or transcode (FFmpeg) to turn MediaRecorder output into a seekable file.

What each browser records

MediaRecorder output by browser Safari records fragmented MP4 with H.264 and AAC. Chrome records WebM with VP8 or VP9 and Opus, and recent versions can also record MP4 with H.264. Firefox records WebM with VP8 and Opus. All produce files without a seek index, which the server should remux. Ask isTypeSupported β€” do not assume browser container video / audio seekable? Safari 14.1+ / iOS fragmented MP4 H.264 / AAC weakly Chrome, Edge WebM (MP4 in newer) VP9 or VP8 / Opus no duration Firefox WebM VP8 / Opus no duration MediaRecorder writes as it goes and never goes back to fill in duration or a cue index β€” so players show 0:00 or cannot seek. A server-side remux (-c copy) fixes it in seconds. Upload whatever the browser produces; normalise on the server.
There is no single format every browser records; detect, record natively, and normalise after upload.

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

  • isTypeSupported in 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 }) throws NotSupportedError for an unsupported type.
  • width/height as ideal, not exact. Exact constraints fail with OverconstrainedError on cameras that cannot produce 1280Γ—720; ideal gets 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 one dataavailable at 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

Wait time after pressing stop, with and without streaming chunks For a two-minute recording on a 10 megabit uplink, uploading after stop means a 31 second wait. Uploading four-second chunks during recording leaves only the last chunk, about 1 second of waiting after stop. 2-minute recording, 10 Mbit/s uplink upload after stop recording 120 s wait 31 s chunks during recording 120 s + uploading as it goes β‰ˆ 1 s start stop pressed The uplink only has to keep up with the recording bitrate (2.6 Mbit/s here) for the wait to vanish. If it cannot, chunks queue up β€” show "finishing upload" with the remaining size, not a spinner.
Streaming chunks turns the wait after "stop" from the whole upload into the last few seconds.

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

Recording flow from explanation to upload complete The user sees an explanation, taps to allow camera, and the browser prompt appears. If denied, show recovery instructions. If allowed, show a live preview, then record with a timer, then stop, finish uploading the last chunk and confirm. Explain β†’ prompt β†’ preview β†’ record β†’ finish explain why camera prompt getUserMedia preview muted video record chunks upload finish last chunk, done denied show how to re-enable Explaining before the prompt measurably raises acceptance; a denied prompt cannot be re-shown by the page.
The permission prompt is a one-shot moment; spend a screen explaining it before you trigger it.

Verification

  1. 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.
  2. 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
  1. 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.