The moment anyone brings up converting video inside the browser, ffmpeg.wasm is the first name that comes up. Almost every implementation you find by searching uses it, it has earned its place as the default choice, and since your existing knowledge of the ffmpeg command carries straight over, the appeal is obvious.
When I built a set of tools that convert images, audio and video inside the browser, I passed on it and went with WebCodecs and mediabunny instead.
This article covers why I left ffmpeg.wasm out, what I used in its place, the things that tripped me up while implementing it, and the processing times I actually measured. I chose WebCodecs and mediabunny for licensing reasons; the difference in speed is something I only found out afterwards by measuring.
One thing to establish up front: this reasoning only holds under the condition that the source is not public and the processing lives on the client. If you convert on the server, you can use ffmpeg as-is, and if your project is open source, the GPL is no obstacle either.
What You'll Learn from This Article
- Why I left ffmpeg.wasm out (licensing)
- The smallest setup that converts video with WebCodecs and mediabunny
- What tends to trip you up when implementing video conversion with WebCodecs
- Measured processing times compared with ffmpeg.wasm
This is written for anyone weighing up an implementation that converts video inside the browser, and it assumes TypeScript and a browser with WebCodecs support (Chrome or Edge).
Why I left ffmpeg.wasm out
I left ffmpeg.wasm out for three reasons: licensing, the memory ceiling, and what the multi-threaded build does to the whole site.
Ship it to the client and the GPL applies
Licensing is the biggest reason, and since this is the spot where people tend to assume that "the GPL has nothing to do with a web service", let me separate the two cases first.
Setup | GPL obligation |
|---|---|
A web app that runs ffmpeg on the server | None (no binary is handed to the user) |
Serving ffmpeg.wasm to the browser | Applies (the wasm reaches the user’s device) |
FFmpeg itself is LGPL 2.1 or later by default, but building it with --enable-gpl and --enable-libx264 turns the whole thing GPL. The @ffmpeg/core package published on npm is built exactly that way, and its package.json states GPL-2.0-or-later as the license (version 0.12.10, as of September 2026). The wrapper @ffmpeg/ffmpeg (0.12.15) is MIT, which makes this easy to mix up, but the codecs live in @ffmpeg/core.
In other words, the exact same feature would have been free to use had I run ffmpeg on the server. The decision to keep everything inside the browser is itself what triggers the GPL obligations. Converting on the server would make the processing itself faster, but it adds upload time and server costs and means holding other people’s files, so I keep the work in the browser. What I build is not open source, so I dropped it here.
You can build your own LGPL version with libx264 excluded, but a build that only drops x264 cannot write H.264. A video converter that cannot write H.264 MP4 files does not do its job, so I ruled that option out from the start.
The memory ceiling
ffmpeg.wasm keeps both the input and the output file in the virtual file system inside wasm. That means the size of the file you are converting eats into memory directly, which puts a practical ceiling on what you can handle.
The tools I run advertise no time limit and no usage limit, so a design that hits a limit on input size does not fit that promise. This was another reason to leave it out.
The multi-threaded build affects the whole site
ffmpeg.wasm does ship a multi-threaded build, and it is faster. It needs SharedArrayBuffer though, which means setting COOP and COEP headers across the entire site. Those headers reach as far as the third-party embeds on your pages, so retrofitting them onto an existing site is not a small job.
What I used instead
The implementation rests on WebCodecs and mediabunny, and the split between them is clean.
Responsible for | Where it comes from | |
|---|---|---|
WebCodecs | The codecs themselves (encode and decode) | Built into the browser |
mediabunny | Container read/write, and a layer over WebCodecs | npm (MPL-2.0; version 1.60.0 as of September 2026) |
WebCodecs is an API that calls the encoders and decoders the browser already has, straight from JavaScript, and because the app ships no codec of its own, no GPL obligation arises at this layer. What WebCodecs deals in is only frames of video and audio, though. Reading and writing containers such as MP4 or WebM is outside what it covers, and mediabunny handles that part.
Choosing mediabunny was closer to elimination than selection: as of September 2026, the same author’s earlier mp4-muxer (5.2.2) and webm-muxer (5.1.4) are both deprecated on npm, with mediabunny named as their successor.
Installing it, and the smallest thing that works

Convert, compress and edit images, audio and video entirely in your browser, with AI upscaling and background removal. Free, no upload, no size limit, no sign-up.
pnpm add mediabunnyIf all you need is a conversion to MP4, this is the whole thing.
import {
ALL_FORMATS, BlobSource, BufferTarget, Conversion,
Input, Mp4OutputFormat, Output, Quality, canEncodeVideo,
} from "mediabunny";
export async function convertToMp4(file: File): Promise<Blob> {
// Encoder support differs per browser, so ask before doing any work
if (!(await canEncodeVideo("avc"))) {
throw new Error("This environment cannot write H.264");
}
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
const conversion = await Conversion.init({
input,
output,
video: { codec: "avc", quality: new Quality("medium") },
});
await conversion.execute();
return new Blob([output.target.buffer], { type: "video/mp4" });
}Conversion handles demuxing, decoding, encoding and muxing together, so you never write code that walks frames one at a time. Changing the resolution or frame rate is a matter of adding height or frameRate to video.
Passing BlobSource is the part that matters: instead of reading the whole input file into memory, it reads only the range it needs as the conversion moves along. The output side is different: BufferTarget holds the finished output in a single ArrayBuffer, so the file you write out does take up memory.
What tripped me up
What follows is the set of things reading the documentation would not have saved me from.
Try mediabunny before Web Audio when decoding
When expanding audio to PCM, Web Audio’s decodeAudioData is tempting. The catch is that this API resamples to the sample rate of the AudioContext (48kHz in most environments) even if you do not ask for it. Load a 44.1kHz source and 48kHz is what you get back, which rules it out whenever you need the original rate preserved.
So I try mediabunny’s decoder first and only fall back to Web Audio when that fails, and I have not changed that order since.
Encoder support varies by browser and OS
This is where WebCodecs caught me out the hardest. Even within Chrome, which codecs you get depends on the OS. Concretely, the Linux build of Chromium has no AAC encoder.
That is why I query canEncodeVideo() and canEncodeAudio() beforehand and reject unsupported formats before any processing starts. When a video can be encoded but its audio cannot, I write out the video alone and put "audio was dropped" on screen. Dropping it silently means the user finds out only after downloading a silent video.
The result of canEncodeAudio() is memoized
This behaviour is not described in the documentation. mediabunny’s canEncodeAudio() memoizes its result, so calling it once before you register an encoder locks that false in place.
The MP3 encoder comes from @mediabunny/mp3-encoder and has to be registered separately. Because I had a support check running ahead of that registration, the app went on reporting "MP3 cannot be written" long after the encoder was in place. It now asks WebCodecs directly.
React’s development mode runs useEffect twice, so the registration fires twice as well. I share the registration promise and await that instead.
mediabunny’s memoization of these checks caused a real bug after I published. It is not only the settled result that gets shared: the in-flight promise itself is shared too (see mediabunny’s encode.ts). So when one probe never returns, every other piece of code waiting on the same question stops as well.
On my setup, AudioEncoder.isConfigSupported({ codec: "mp3" }) did not return for about 43 seconds. Every WebCodecs probe on the page stalled behind it, and the video conversion made no progress during that time. Neither CPU nor bandwidth was in use during that window, so I spent a long time assuming the processing was heavy when it was simply waiting.
The probes now have a time limit and move on when nothing comes back. If the format really is unsupported, encoder initialization fails anyway, so there is nothing to gain from waiting on a probe forever.
// Capability probes sometimes never return, so cap how long we wait
async function withProbeTimeout<T>(probe: Promise<T>, fallback: T, ms = 5000): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
probe,
new Promise<T>((resolve) => {
timer = setTimeout(() => resolve(fallback), ms);
}),
]);
} finally {
clearTimeout(timer);
}
}Measured: how far apart are they?
Speed was not my reason for choosing, but measuring made the gap plain.
The source is a file generated with ffmpeg: 20 seconds, 1280x720, 30fps, H.264 + AAC, 8.2 MB. The machine is an Ubuntu server with a Ryzen 7 8745H (8 cores, 16 threads, integrated Radeon 780M GPU), the browser is Chromium, and every run used the same conditions.
Method | Time | Output size |
|---|---|---|
ffmpeg.wasm, single-threaded ( | 20.1 s | 6.3 MB (about 2.5 Mbps) |
ffmpeg.wasm, single-threaded ( | abandoned past 10 minutes | — |
WebCodecs + mediabunny (the code in this article) | 0.82 s | 4.05 MB (1.49 Mbps) |
A 20-second video finishing in 0.82 seconds works out to roughly 24 times real time. Running the output through ffprobe shows the video bitrate moved from 3.13 Mbps to 1.49 Mbps, so this is a genuine re-encode rather than a container remux, done in that time.
The gap is this wide most likely because WebCodecs calls the encoder built into the browser, while ffmpeg.wasm runs the x264 code on top of wasm. The former can use hardware support; the latter is computation inside a JavaScript engine.
Do note that the ffmpeg.wasm figure depends heavily on which preset you pick. What took 20 seconds at veryfast had not finished after 10 minutes at medium. Whenever you come across a speed comparison, check which preset it was measured with.
Summary
For converting video inside the browser, I went with WebCodecs and mediabunny rather than ffmpeg.wasm. Not because it is faster, but because shipping to the client means the GPL applies, and that single point decided it.
- Running ffmpeg on the server carries no GPL distribution obligation; serving wasm to the browser does
- WebCodecs uses the codecs already in the browser, so the app ships none of its own
- Leave container read/write to mediabunny; the same author’s
mp4-muxerandwebm-muxerare deprecated, with mediabunny as their successor - Encoder support differs by browser and OS, so always query before processing
- The speed gap was real: 20.1 seconds with ffmpeg.wasm (
-preset veryfast) against 0.82 seconds with WebCodecs, on a 20-second video
Related articles and tools

A JavaScript library for reading, writing, and converting media files. Directly in the browser, and faster than anybunny else.
No install, no upload. Transcribe a video, fix the SRT, and burn the subtitles into the picture — all inside your browser, free and without an account.
