I have a confession: I built a video compression tool and it has zero server costs.
Not because I'm cheap (well, maybe a little), but because the entire processing happens in your browser. No uploads. No waiting for a server to process your file. No wondering who's looking at your private videos.
Here's how I did it with FFmpeg.wasm.
The Problem
I wanted to build a suite of file utility tools — image compression, PDF editing, video conversion. The image and PDF parts were straightforward using Canvas API and pdf-lib. But video? Video processing is supposed to need a server.
Or so I thought.
Enter FFmpeg.wasm
FFmpeg is the industry-standard tool for video processing. It's written in C, runs on servers, and powers YouTube, Netflix, and VLC.
But someone compiled it to WebAssembly. That means it runs in a browser.
npm install @ffmpeg/ffmpeg @ffmpeg/util
That's it. You now have FFmpeg in your browser.
The First Wall: SharedArrayBuffer
The moment I tried to run it, I got:
SharedArrayBuffer is not defined
FFmpeg.wasm uses multithreading, which requires SharedArrayBuffer. This API is disabled by default for security reasons (Spectre attacks). To enable it, you need specific HTTP headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
In Next.js, add this to next.config.js:
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
],
},
];
},
};
Works locally. Deploy to Vercel and... still broken. Vercel needs the same headers in vercel.json:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
]
}
]
}
The Virtual Filesystem
FFmpeg reads and writes files. Browsers don't have a filesystem. FFmpeg.wasm provides an in-memory virtual filesystem (Emscripten FS).
const ffmpeg = new FFmpeg();
await ffmpeg.load();
const { fetchFile } = await import("@ffmpeg/util");
// Write to virtual filesystem
await ffmpeg.writeFile("input.mp4", await fetchFile(file));
// Run FFmpeg command
await ffmpeg.exec(["-i", "input.mp4", "-crf", "28", "output.mp4"]);
// Read result
const data = await ffmpeg.readFile("output.mp4");
// Convert to downloadable blob
const blob = new Blob([data.buffer], { type: "video/mp4" });
const url = URL.createObjectURL(blob);
Showing Real Progress
FFmpeg.wasm doesn't give you a clean progress percentage out of the box for container conversions. But it does log messages — and those logs contain timing information.
ffmpeg.on("log", ({ message }) => {
// Parse total duration
const durMatch = message.match(/Duration:\s*(\d+:\d+:\d+\.?\d*)/);
if (durMatch) {
totalDuration = timeToSeconds(durMatch[1]);
}
// Parse current position
const timeMatch = message.match(/time=(\d+:\d+:\d+\.?\d*)/);
if (timeMatch && totalDuration > 0) {
const current = timeToSeconds(timeMatch[1]);
const progress = Math.min(Math.round((current / totalDuration) * 100), 99);
setProgress(progress);
}
});
function timeToSeconds(timeStr) {
const parts = timeStr.split(":").map(parseFloat);
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
This gives you a real progress bar based on actual processing position.
The 30MB Elephant in the Room
FFmpeg.wasm core is ~30MB. Users need to download it before processing starts.
I handle this with a clear loading state:
const [ffmpegLoaded, setFfmpegLoaded] = useState(false);
const loadFFmpeg = async () => {
const ffmpeg = new FFmpeg();
await ffmpeg.load({
coreURL: "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js",
});
setFfmpegLoaded(true);
return ffmpeg;
};
The good news: browsers cache it. Second visit = instant load.
What You Can Do With It
Once you have FFmpeg in the browser, the possibilities are surprising:
// Compress video
["-i", "input.mp4", "-crf", "28", "-preset", "fast", "output.mp4"]
// Convert format
["-i", "input.mov", "output.mp4"]
// Extract audio
["-i", "input.mp4", "-vn", "-acodec", "copy", "output.mp3"]
// Convert to GIF
["-i", "input.mp4", "-vf", "fps=10,scale=480:-1", "output.gif"]
// Trim
["-i", "input.mp4", "-ss", "00:00:10", "-to", "00:00:30", "-c", "copy", "output.mp4"]
Performance Reality Check
Browser processing is slower than server processing. On my M2 MacBook, compressing a 100MB MP4 takes about 2-3 minutes. On a mid-range Android phone, closer to 8-10 minutes.
That's the trade-off. You get:
- ✅ Zero server costs
- ✅ Complete privacy (files never leave the device)
- ✅ Works offline after initial load
- ❌ Slower than server-side processing
- ❌ Limited by device RAM (large files may fail)
Mobile Works Too
This was the surprise. Both iOS Safari and Android Chrome support WebAssembly. You can compress video on a phone without uploading it anywhere.
The Result
I shipped this as part of ToolZip — a browser-based utility suite. Video compression, format conversion, GIF export, audio extraction — all client-side.
The reaction I didn't expect: people specifically mention the "no upload" aspect in feedback. Privacy matters to users more than I anticipated.
Key Takeaways
FFmpeg.wasm is production-ready — The API is stable and the WebAssembly performance is acceptable for most use cases.
Headers are critical — The SharedArrayBuffer requirement trips up many deployments. Set them early.
Parse logs for progress — Don't rely on the progress event for format conversions; parse log output instead.
Cache aggressively — 30MB is a one-time cost. After that, it's instant.
Set expectations — Tell users processing takes time and not to close the tab. Transparency prevents frustration.
The browser is more capable than most developers realize. Sometimes the server is optional.
ToolZip — 52 browser-based tools for file conversion, editing, and utilities. No uploads, no accounts, no cost.
Top comments (0)