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-originandCross-Origin-Embedder-Policy: require-corpheaders, 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
| Approach | Best for | Tradeoff |
|---|---|---|
| ffmpeg.wasm | Privacy-first conversion (no upload), MediaRecorder output | ~30MB WASM, slow, COOP/COEP headers required |
| fluent-ffmpeg (Node) | Server-side batch, native FFmpeg speed | FFmpeg binary install required |
| ChangeThisFile API | Edge runtimes, multi-tenant, no infra | Network 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.