What if you could convert video files entirely in the browser, with no server involved? No upload wait times. No infrastructure costs. No user files ever leaving the device.
ffmpeg.wasm makes this possible. It's FFmpeg compiled to WebAssembly, and it runs entirely on the client side. In this article we'll go from installation to a working React/Next.js file converter component.
Processing Video Without a Server
Traditional browser-based video conversion tools work like this:
- User uploads a video file to the server
- The server runs FFmpeg and converts the file
- The user downloads the converted result
This approach has two problems: it's slow (files transfer twice) and it costs money (server CPU). For large files, users can wait minutes just for the round trip.
ffmpeg.wasm is FFmpeg's C source compiled to WebAssembly via Emscripten. It runs inside the browser's JavaScript engine. All processing happens on the client — no file transfer to a server ever occurs.
Main advantages:
- No server infrastructure required (pure frontend)
- Files never leave the user's device — strong privacy guarantee
- Works offline after the WASM binary is cached
- Zero scaling cost
Realistic downsides:
- The WASM binary takes a few seconds to download on first load
- Processing speed is slower than native FFmpeg (often 5–20x slower)
- Large files can hit memory limits and fail
For small-to-medium files in practical conversion scenarios, the tradeoff is well worth it.
Setup and Basic Usage
Install the packages:
npm install @ffmpeg/ffmpeg @ffmpeg/util
Here is the basic usage pattern:
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
const ffmpeg = new FFmpeg();
async function convertVideo(inputFile: File): Promise<Blob> {
// Load the WASM binary (only on first call)
if (!ffmpeg.loaded) {
const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd";
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(
`${baseURL}/ffmpeg-core.wasm`,
"application/wasm"
),
});
}
// Write the input file into ffmpeg.wasm's virtual filesystem
await ffmpeg.writeFile("input.mp4", await fetchFile(inputFile));
// Run the FFmpeg command (MP4 → WebM conversion)
await ffmpeg.exec(["-i", "input.mp4", "-c:v", "libvpx-vp9", "output.webm"]);
// Read the output file back
const data = await ffmpeg.readFile("output.webm");
// Convert Uint8Array → Blob and return
return new Blob([data], { type: "video/webm" });
}
The array you pass to ffmpeg.exec() maps directly to FFmpeg command arguments after the ffmpeg binary name. ffmpeg -i input.mp4 output.webm becomes ["-i", "input.mp4", "output.webm"].
The SharedArrayBuffer Wall (COOP/COEP Headers)
To enable SharedArrayBuffer, your server must respond with two HTTP headers:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp
For Next.js, add this to next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return [
{
// Apply to all routes. Narrow the source path if you only
// need ffmpeg.wasm on specific pages.
source: "/(.*)",
headers: [
{
key: "Cross-Origin-Opener-Policy",
value: "same-origin",
},
{
key: "Cross-Origin-Embedder-Policy",
value: "require-corp",
},
],
},
];
},
};
export default nextConfig;
Side effects to be aware of:
COOP/COEP restrict cross-origin resource loading. If your site loads external fonts, Google Analytics scripts, or other third-party assets, those will need crossorigin="anonymous" attributes or a Cross-Origin-Resource-Policy header on the resource server. Most major CDNs already send this header, but it can cause unexpected breakage if you haven't thought about it.
If you use the single-threaded build (@ffmpeg/core instead of @ffmpeg/core-mt), SharedArrayBuffer is not required and you can skip the COOP/COEP headers — at the cost of slower processing since multiple CPU cores won't be used. Starting with the single-threaded build is the easiest way to get up and running.
When deploying to Vercel, the next.config.ts headers config is respected automatically — no additional Vercel-specific configuration needed.
Video Conversion Examples
Here are practical conversion examples for common use cases.
MP4 → WebM (VP9, file size optimized):
await ffmpeg.exec([
"-i",
"input.mp4",
"-c:v",
"libvpx-vp9",
"-crf",
"33", // Quality (lower = higher quality, higher = smaller file)
"-b:v",
"0", // VBR mode (used together with -crf)
"-c:a",
"libopus",
"output.webm",
]);
MP4 → GIF (first 5 seconds):
await ffmpeg.exec([
"-i",
"input.mp4",
"-t",
"5", // Stop after 5 seconds
"-vf",
"fps=15,scale=480:-1:flags=lanczos", // Frame rate and resize
"-loop",
"0",
"output.gif",
]);
Extract audio from video (MP3):
await ffmpeg.exec([
"-i",
"input.mp4",
"-vn", // Discard video stream
"-c:a",
"libmp3lame",
"-q:a",
"2", // Quality (0-9, lower = higher quality)
"output.mp3",
]);
To track progress, register a listener with ffmpeg.on("progress", callback) before calling exec:
ffmpeg.on("progress", ({ progress, time }) => {
// progress: 0.0 to 1.0
// time: current position in the video being processed (microseconds)
console.log(`Progress: ${Math.round(progress * 100)}%`);
});
Building a React/Next.js Component
Here is a complete file converter component that users can drop a file into and receive a converted download.
"use client";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
import { useRef, useState } from "react";
const CORE_BASE_URL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd";
export function VideoConverter() {
const ffmpegRef = useRef<FFmpeg>(new FFmpeg());
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState<number | null>(null);
const [outputUrl, setOutputUrl] = useState<string | null>(null);
async function loadFFmpeg() {
if (loaded) return;
setLoading(true);
const ffmpeg = ffmpegRef.current;
await ffmpeg.load({
coreURL: await toBlobURL(
`${CORE_BASE_URL}/ffmpeg-core.js`,
"text/javascript"
),
wasmURL: await toBlobURL(
`${CORE_BASE_URL}/ffmpeg-core.wasm`,
"application/wasm"
),
});
ffmpeg.on("progress", ({ progress }) => {
setProgress(Math.round(progress * 100));
});
setLoaded(true);
setLoading(false);
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
await loadFFmpeg();
const ffmpeg = ffmpegRef.current;
setProgress(0);
setOutputUrl(null);
// Write the input file to the virtual filesystem
await ffmpeg.writeFile("input.mp4", await fetchFile(file));
// Run conversion
await ffmpeg.exec(["-i", "input.mp4", "-c:v", "libvpx-vp9", "-crf", "33", "-b:v", "0", "output.webm"]);
// Read output and create a download URL
const data = await ffmpeg.readFile("output.webm");
const blob = new Blob([data as Uint8Array], { type: "video/webm" });
const url = URL.createObjectURL(blob);
setOutputUrl(url);
setProgress(null);
}
return (
<div className="space-y-4 p-6 border rounded-lg">
<h2 className="text-xl font-bold">Video Converter (MP4 → WebM)</h2>
<input
type="file"
accept="video/mp4"
onChange={handleFileChange}
disabled={loading || progress !== null}
className="block"
/>
{loading && (
<p className="text-sm text-muted-foreground">Loading FFmpeg...</p>
)}
{progress !== null && (
<div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-sm mt-1">{progress}%</p>
</div>
)}
{outputUrl && (
<div className="space-y-2">
<video src={outputUrl} controls className="w-full max-w-lg" />
<a
href={outputUrl}
download="output.webm"
className="inline-block px-4 py-2 bg-primary text-primary-foreground rounded"
>
Download
</a>
</div>
)}
</div>
);
}
A few things worth noting about this implementation. The FFmpeg instance lives in a useRef — not useState — because you don't want React to recreate it on every render. Loading the WASM binary is expensive, so you want to do it once and reuse the same instance.
The URL.createObjectURL call creates a Blob URL that should be revoked when it's no longer needed. For production code, call URL.revokeObjectURL(url) in a useEffect cleanup function to avoid memory leaks.
Limitations and Performance
Processing speed:
ffmpeg.wasm is noticeably slower than native FFmpeg. A rough benchmark: converting a 5-minute MP4 to WebM that takes 30 seconds natively might take 3–10 minutes in the browser, depending on device CPU. This is an inherent cost of the WASM sandbox.
Codec availability:
The default ffmpeg.wasm build includes a limited codec set. Codecs with patent complications — like H.265 (HEVC) encoding or AV1 encoding — may not be available in the standard build. Common use cases (H.264 decode, VP9 encode, format conversion) work fine.
Browser compatibility:
Any modern browser supporting WebAssembly and SharedArrayBuffer will work: Chrome 92+, Firefox 79+, Safari 15.2+. IE is unsupported, but in 2026 that's rarely a concern.
Threading model:
ffmpeg.wasm uses Web Workers internally, so conversions don't block the main thread — your UI stays responsive during processing. The progress callback fires from within the Worker, so you can drive a progress bar without any extra threading logic on your side.
Wrapping Up
ffmpeg.wasm opens the door to serverless video processing tools built entirely on the frontend.
Key takeaways:
- Use v0.12+ API: Import the
FFmpegclass and instantiate withnew FFmpeg()— not the oldcreateFFmpeg()factory - Set COOP/COEP headers: Cross-Origin Isolation is required for SharedArrayBuffer; wire it up in
next.config.tsbefore you do anything else - Guard against large files: Files over 1 GB can exhaust browser memory — add a size check in your UI
- Keep the FFmpeg instance in a ref: Use
useRefto hold it across renders and avoid paying the WASM load cost more than once - Set expectations on speed: Processing is slower than native; a progress bar makes the wait bearable
For small-to-medium file conversion, GIF generation, and audio extraction, ffmpeg.wasm delivers practical performance. If you're building a privacy-first tool where sending files to a server is a non-starter, or a service where you want zero infrastructure cost per conversion, ffmpeg.wasm is one of the most interesting tools in the frontend ecosystem right now.