How I Built a 100% Client-Side File Converter with FFmpeg WASM

A few months back I needed to convert maybe 40 HEIC photos from my phone into JPG. Nothing fancy. The first result on Google wanted me to upload them. All of them. To some server in who-knows-where.
I closed the tab.
The thing is, those photos had my kid's face in them. I'm not uploading that to a stranger's bucket just because Safari decided HEIC was a good idea in 2017.
So I built my own converter. Everything runs in the browser. No backend. No upload endpoint. Not even a /health route. If you open devtools and watch the Network tab while converting, you see zero traffic after the initial page load.
Here's what I learned.
The whole stack fits in the browser now
This wasn't true five years ago. It barely was true two years ago. But in 2026, you can do almost any file conversion client-side if you pick the right tool per format.
The split I landed on:
Images go through Canvas API for common stuff (PNG, JPG, WebP) and
libheif-jsfor HEIC.Video and audio go through
@ffmpeg/ffmpeg(that's FFmpeg compiled to WASM).PDFs go through
pdf.jsfor rasterization.
Three engines. One UI. No server.
FFmpeg WASM is the big one. A 30MB download the first time, cached forever after. It can transcode mp4 to webm, extract audio from video, convert wav to mp3, basically whatever native FFmpeg does, except slower.
Loading it looks like this:
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
const ffmpeg = new FFmpeg();
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'),
});
Simple. The gotchas come later.
The header thing nobody warns you about
First time I deployed, FFmpeg refused to load. Error about SharedArrayBuffer being undefined.
Turns out WASM threading needs two HTTP headers set on your page response:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
These are called COOP and COEP. Without them, SharedArrayBuffer is disabled for security reasons (Spectre mitigations from 2018 kept them gated by default). Without SharedArrayBuffer, multi-threaded FFmpeg falls back to single-threaded, and a 200MB video conversion goes from 40 seconds to 8 minutes.
If you're on Next.js, the config is:
// next.config.js
module.exports = {
async headers() {
return [{
source: '/(.*)',
headers: [
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
{ key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' },
],
}];
},
};
The catch: COEP breaks third-party scripts that don't send the right CORP header. Google Analytics, Stripe embeds, basically anything cross-origin stops loading. You either host those scripts yourself, or you switch to credentialless mode which is less strict but works in Chrome/Firefox only.
I went with credentialless. Works fine.
Chrome has a hard 2GB memory ceiling
This one took me two weekends to figure out. A user reported that converting a 1.2GB 4K video crashed the tab. No error. Just the sad-face-page.
WASM in Chrome gets 2GB of memory max. Not 2GB free, 2GB total including the WASM module itself and anything FFmpeg allocates internally. For video transcoding, FFmpeg buffers frames, and a single 4K frame is ~25MB decoded. You do the math on a 10-minute clip at 30fps.
The fix is chunking, but FFmpeg doesn't natively stream. So the honest answer I ship to users is: files over ~500MB won't work in the browser. For bigger stuff I have a desktop app coming that bundles native FFmpeg. But for 99% of use cases (phone videos, voice memos, doc pages), 500MB is fine.
I put the check at upload:
if (file.size > 500 * 1024 * 1024) {
showError('File too large for browser conversion. Try the desktop app.');
return;
}
Not elegant. But honest.
Canvas for images, because FFmpeg is overkill
Early on I used FFmpeg for everything including PNG to JPG. Stupid. Loading 30MB of WASM to do something the browser can natively do in 2 lines is absurd.
Canvas API handles nearly all standard image conversions:
async function pngToJpg(file) {
const img = await createImageBitmap(file);
const canvas = new OffscreenCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 });
}
Native browser code. Fast. Zero dependencies. Works in web workers (OffscreenCanvas specifically).
The only image format Canvas refuses is HEIC. Apple never bothered. For that I use libheif-js, which is its own WASM bundle but way smaller than FFmpeg, around 1.5MB. Decode once to raw RGBA, push into Canvas, export as whatever.
You can poke at the live result at the HEIC to JPG tool I built for this if you want to see what the whole flow feels like.
Routing: one UI, three engines
My tool registry is a single TypeScript array. Each entry says which engine to use, what input formats are accepted, what the output is. New pages get generated at build time.
{
slug: 'mp4-to-mp3',
category: 'audio',
engine: 'ffmpeg',
inputFormats: ['.mp4', '.mov'],
outputFormat: '.mp3',
ffmpegArgs: ['-vn', '-ab', '192k', '-ar', '44100'],
}
Next.js reads the array at build and produces one static page per entry. SEO sitemap gets regenerated. OG images get regenerated. Adding wma-to-mp3 took me 90 seconds yesterday.
This pattern isn't new. Programmatic SEO people have been doing it for years. But combining it with client-side WASM conversion means every page is both a marketing surface and a working tool. No "sign up for the beta" crap.
Performance check, honestly
Here's what the reality is, not the marketing version:
| Conversion | File size | Time on M2 Mac | Time on mid Android |
|---|---|---|---|
| HEIC to JPG | 4MB photo | 0.3s | 0.8s |
| PNG to WebP | 2MB image | 0.1s | 0.2s |
| MP4 to MP3 (extract audio) | 50MB video | 6s | 18s |
| MP4 to WebM (transcode) | 50MB video | 45s | 2 min 40s |
| WAV to MP3 | 30MB audio | 3s | 9s |
Video transcoding is where WASM suffers hardest. Native FFmpeg does the same job in maybe a quarter of the time. If you're doing bulk video work, use the desktop tool, not the browser. If you're converting your kid's piano recital so grandma can watch it on her iPad, the browser is plenty.
Stuff I'd do differently if I started today
Would keep: Engine-per-format routing, Canvas for simple stuff, tool registry as single source of truth, no backend.
Would change:
Ship a service worker from day one. I added it later and retrofitting offline support was painful.
Pick
@ffmpeg/ffmpeg@0.12.xfrom the start. 0.11 had a different API and I migrated once already.Not store the built WASM in my git repo. Pull from CDN. I bloated my repo early on and regretted it.
Still figuring out:
AVIF encoding client-side. Canvas decodes AVIF but doesn't encode. I'm watching
@jsquash/avifwhich wraps libavif to WASM but it's 4MB and I'm not sure the use case is big enough yet.Progress reporting across web workers without janky postMessage loops.
The point
If you're building something that touches user files, and those files are private by nature (photos, documents, audio memos), you probably don't need a backend. You probably don't want one. Servers are a liability the moment something sensitive lands on them.
WASM lets you ship real tools that run entirely on the user's machine. It's not magic. It has limits. But for a huge range of what people actually need, the browser is enough.
If you want to see this in action, the whole thing runs at ConvertBruv, a browser-based file converter I built on top of this stack. The VS Code extension is already live in the marketplace, same engines wrapped in an editor panel. Chrome and Firefox extensions are in review right now (should land in a week or two, you know how those queues are). A desktop app is in the works too for the cases where the browser just can't cope, like multi-gig video batches. Code is the same core engines wrapped in different UI shells.
Feel free to yell at me in the comments if I got something wrong. I definitely did somewhere.
