WebM uses VP8/VP9 video and Vorbis/Opus audio — none of which MP4 supports natively. So WebM-to-MP4 always requires re-encoding (no stream-copy fast path). The most common source: MediaRecorder API output. The browser-side option (ffmpeg.wasm) is heavy but lets you avoid uploading user video.

Method 1: ffmpeg.wasm (browser, client-side)

ffmpeg.wasm is a WebAssembly port of FFmpeg. Runs entirely in the browser — no server, no upload. Best for privacy-sensitive use cases (e.g., screen recordings, webcam captures from MediaRecorder).

npm install @ffmpeg/ffmpeg @ffmpeg/util
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";

const ffmpeg = new FFmpeg();
const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd";

async function loadFfmpeg() {
  await ffmpeg.load({
    coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
    wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
  });
}

async function webmToMp4(webmFile) {
  if (!ffmpeg.loaded) await loadFfmpeg();

  await ffmpeg.writeFile("input.webm", await fetchFile(webmFile));
  await ffmpeg.exec([
    "-i", "input.webm",
    "-c:v", "libx264",
    "-preset", "medium",
    "-crf", "23",
    "-c:a", "aac",
    "-b:a", "128k",
    "-movflags", "+faststart",
    "output.mp4",
  ]);
  const data = await ffmpeg.readFile("output.mp4");
  return new Blob([data], { type: "video/mp4" });
}

// Wire to a file input or MediaRecorder output:
document.querySelector("input[type=file]").addEventListener("change", async (e) => {
  const mp4Blob = await webmToMp4(e.target.files[0]);
  const url = URL.createObjectURL(mp4Blob);
  const a = Object.assign(document.createElement("a"), {
    href: url,
    download: "output.mp4",
  });
  a.click();
  URL.revokeObjectURL(url);
});

Three things to know:

  • ~30MB WASM core download. First-load cost is significant — cache it via service worker or load on demand. After that, conversions are local.
  • Encoding is slow in WASM. A 1-minute WebM takes ~1 minute to convert in the browser. About 5-10x slower than native FFmpeg.
  • Requires SharedArrayBuffer. Your page must serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers, or ffmpeg.wasm won't load.

Method 2: fluent-ffmpeg (Node)

For server-side conversion in Node, fluent-ffmpeg wraps the FFmpeg binary with a fluent API.

npm install fluent-ffmpeg
apt install ffmpeg  # binary required on host
import ffmpeg from "fluent-ffmpeg";
import { promisify } from "node:util";

function webmToMp4(inPath, outPath) {
  return new Promise((resolve, reject) => {
    ffmpeg(inPath)
      .videoCodec("libx264")
      .audioCodec("aac")
      .audioBitrate("128k")
      .outputOptions([
        "-preset medium",
        "-crf 23",
        "-movflags +faststart",
      ])
      .on("end", resolve)
      .on("error", reject)
      .save(outPath);
  });
}

await webmToMp4("recording.webm", "recording.mp4");

fluent-ffmpeg is the de facto Node wrapper. Cleaner than constructing argument arrays for child_process.spawn, but ultimately just runs the FFmpeg binary.

Method 3: ChangeThisFile API (no FFmpeg, no WASM)

If 30MB of WASM is too much for your page, or you don't want FFmpeg in your Node container, the API runs FFmpeg server-side. Free tier covers 1,000 conversions/month.

const API_KEY = "ctf_sk_your_key_here";

async function webmToMp4(webmBuffer, filename = "video.webm") {
  const form = new FormData();
  form.append("file", new Blob([webmBuffer], { type: "video/webm" }), filename);
  form.append("source", "webm");
  form.append("target", "mp4");

  const response = await fetch("https://changethisfile.com/v1/convert", {
    method: "POST",
    headers: { Authorization: `Bearer ${API_KEY}` },
    body: form,
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  return await response.arrayBuffer();
}

// Cloudflare Worker example:
export default {
  async fetch(request) {
    const webm = await request.arrayBuffer();
    const mp4 = await webmToMp4(new Uint8Array(webm));
    return new Response(mp4, {
      headers: { "Content-Type": "video/mp4" },
    });
  },
};

The API encodes server-side at native speed (~5-10x faster than ffmpeg.wasm). Output is web-ready (faststart, AAC audio).

When to use each

ApproachBest forTradeoff
ffmpeg.wasmPrivacy-first conversion (no upload), MediaRecorder output~30MB WASM, slow, COOP/COEP headers required
fluent-ffmpeg (Node)Server-side batch, native FFmpeg speedFFmpeg binary install required
ChangeThisFile APIEdge runtimes, multi-tenant, no infraNetwork call, file size limit (25MB free)

Production tips

  • For ffmpeg.wasm, lazy-load the core. 30MB on first page load is brutal. Load only when the user clicks 'Convert'.
  • Cross-origin headers are mandatory. Without COOP/COEP, ffmpeg.wasm fails silently. Cloudflare Pages: configure in _headers; Vercel: vercel.json.
  • Use Web Worker for ffmpeg.wasm. WASM conversion blocks the main thread. Run it in a worker so the page stays responsive.
  • For MediaRecorder output, MP4 conversion is non-optional in some cases. Safari can't play WebM. iOS users need MP4. Convert before download/share.
  • Set timeout 300s+ for big videos. A 5-minute WebM takes ~5 minutes in WASM and ~30 seconds via the API.

For client-side privacy, ffmpeg.wasm with WebWorker. For Node services, fluent-ffmpeg. For edge or no-infra environments, the API. Free tier covers 1,000 conversions/month.