WebCodecs is 7.5–10.1× faster than ffmpeg.wasm. That's the headline, and it's the least interesting thing we measured.
In the same benchmark, at identical settings, ffmpeg.wasm produced a smaller file — 10.23 MB against WebCodecs' 11.20 MB. Not noise, not a bug.
And there's a 5× performance spread hiding inside WebCodecs itself that no feature detection will tell you about.
A while back I wrote up three things that broke when I moved video compression into the browser — the AVI remux that produced a 1151-byte file, the AAC priming delay that permanently defeats mediabunny's copy path, and why the multithreaded ffmpeg core costs you your ad revenue. That post was about the failures. This one is about the numbers.
The comparison is usually posed wrong
Two mechanisms compress video in a browser without uploading it. ffmpeg.wasm is FFmpeg compiled to WebAssembly — the whole toolchain running as a program in the tab. WebCodecs is a browser API that hands JavaScript the platform's own encoder, usually the same hardware block your phone uses to record video.
Written up as a versus, the answer is always some variation of "WebCodecs if you can, ffmpeg.wasm if you must."
That's true, and it's not very useful. It describes a decision made once, at the top of a project. The decision that actually matters is made per file — and the two engines are not the only options on that decision.
The measurements
Same clip, same settings, compressed twice through the production UI.
The ffmpeg run is not a debug mode. The harness deletes window.VideoEncoder and window.VideoDecoder before any page script runs, so the engine selector takes the fallback branch exactly as it would in a browser that never shipped the API.
Both runs start from a cold browser context, so the ffmpeg figures include fetching its WebAssembly core — a real visitor pays for that too.
| Clip | Source | WebCodecs | ffmpeg.wasm | Slower by | Output (WC / ff) |
|---|---|---|---|---|---|
| 5 s, 1080p | 5.2 MB | 1.3 s | 9.8 s | 7.5× | 3.24 / 3.33 MB |
| 15 s, 1080p | 15.6 MB | 2.8 s | 28.4 s | 10.1× | 9.72 / 9.83 MB |
| 30 s, 720p | 15.9 MB | 2.8 s | 27.4 s | 9.8× | 11.20 / 10.23 MB |
Apple M3, 16 GB, Chromium 145, macOS.
The gap grows with duration. Fetching and instantiating the wasm core is a fixed cost, so on a five-second clip it's a large share of the ten seconds. By fifteen seconds it's been amortised — and the ratio still gets worse, not better. That's the encoder itself, not the startup.
The last row is the interesting one. ffmpeg produced a smaller file on identical settings. libx264 is running CRF with a bitrate ceiling, so it spends what each scene needs and banks the rest. The WebCodecs encoder is given an average bitrate and hits it. On footage with large easy regions, CRF wins that trade comfortably.
So the honest summary isn't "WebCodecs is better." It's:
WebCodecs is much faster and usually close enough on size, and on some content it gives up a real amount of it.
The 5× spread hiding inside WebCodecs
This is the part I'd most like other people to check.
Our router has to guess how long a re-encode will take before committing to one, so it carries a throughput figure in megapixels of source video per second:
| Encoder | Mpx/s |
|---|---|
| WebCodecs, hardware AVC (Apple M-series) | 305 |
| WebCodecs, software fallback (no hardware AVC) | 60 |
| What the router actually assumes for WebCodecs | 150 |
| ffmpeg.wasm, single-threaded libx264 | 15 |
A hardware AVC encoder on an M-series measured 305. The same API on a machine with no hardware AVC — falling back to a software encoder behind the same interface — lands near 60.
That's a 5× spread inside what the code calls "WebCodecs," and nothing in the API tells you which one you got.
VideoEncoder.isConfigSupported() will happily tell you the config is supported. It will not tell you whether it's supported by silicon or by a software path that's five times slower. You can measure it at runtime — but only by encoding something first, which is the thing you were trying to decide about.
So the shipped figure is 150. Deliberately between them, and deliberately not the number either machine produces:
const ENCODE_MEGAPIXELS_PER_SEC = { webcodecs: 150, ffmpegWasm: 15 } as const;
Over-estimating the machine costs the user a wait they didn't agree to. Under-estimating it costs them compression they wanted. Splitting the difference isn't indecision — it's the only position where both errors stay small.
(ffmpeg.wasm gets 15 because we're on the single-threaded core. That's a revenue decision, not an oversight — explained in the earlier post.)
Four routes, not three
The earlier post described three paths. Shipping it properly turned out to need four — and the two that are neither engine are where a surprising share of the wins live.
| Route | What runs | When |
|---|---|---|
| Passthrough | Copy encoded samples into a new container. No decode, no encode. | Already meets target, is H.264 + AAC, and re-encoding would take >20 s |
| WebCodecs transcode | Decode + re-encode via the browser's codec API | Browser exposes WebCodecs, can read the container, can encode the target |
| Remux first, then transcode | Losslessly rewrap to Matroska with ffmpeg.wasm, then hand to WebCodecs | WebCodecs can't read the container (AVI, FLV, WMV) but can decode the codec inside |
| ffmpeg.wasm | Full libx264 encode in wasm | Nothing above applies |
The engine decision itself is trivial:
export function decideEngine(caps: EngineCaps): CompressionEngine {
return caps.hasWebCodecsApi && caps.inputReadable && caps.canEncodeTarget
? "webcodecs"
: "ffmpeg";
}
All the interesting work is in deciding whether to encode at all, and in making an unreadable container readable.
Passthrough: the threshold is on the wrong axis if you use bitrate
Copying encoded samples buys exactly one thing: time. It can't win on size, because the encoder is separately instructed to spend at most the source's own bitrate — so a re-encode is essentially always smaller than a copy.
On a five-second clip, skipping the encode saved 0.8 seconds and forfeited about 19% of the file. Nobody asked for that trade.
On a ten-minute file the same skip saves minutes.
So the rule isn't about bitrate at all. It's about the wait:
export const PASSTHROUGH_MIN_TRANSCODE_SECONDS = 20;
A threshold on the wrong axis would have been easy to ship and hard to notice.
One trap while you're in there:
A source's audio codec being unknown is not the same as it having no audio.
The two want opposite handling — an unidentified track is precisely the kind you must not copy blind. Collapse both into a null check and you get silent audio in an output that otherwise looks fine.
What remux-first costs in memory
Rewrapping an unreadable container so the fast encoder can take the job is the single best trick in the pipeline. Getting it to work at all was its own adventure. What I didn't appreciate until later is what it costs.
The Matroska output is built in the wasm heap → copied out to a Uint8Array → copied again into a File → and then mediabunny accumulates the whole MP4 output in a buffer on top of that.
Four resident near-source-sized copies, against the plain transcode's zero — that path mounts the input through the filesystem shim, and its output is smaller than its input.
So the fast path has a ceiling: 256 MiB of source on the 8 GB that nearly every desktop Chrome reports. Above it we simply transcode with ffmpeg, exactly as before remux-first existed.
An optimisation that declines to apply is fine. An out-of-memory crash on a file that used to work is not.
And if you budget against that number, know this:
navigator.deviceMemory caps its reported value at 8. A 64 GB workstation and an 8 GB laptop are indistinguishable through that API. Budgets keyed to it are keyed to a number that stops rising.
The second bug that only a real browser found
Last time it was an AVI that came out as 1151 bytes — loud, obvious once you looked. This one was the opposite, and I think it's the more instructive shape.
There's a harness that drives a corpus of real files through the full pipeline in an actual Chromium, pulls the output back out, inspects it with ffprobe, and observes which route each file took by stubbing the analytics sink the page already calls.
It earned its keep on a regression that both code review and unit tests missed.
The ffmpeg probe parses ffmpeg's own log output to learn what streams a file contains. It had begun reading ffmpeg's description of its own output stream as though it were an input stream.
Nothing threw. Nothing failed a test.
AVI, FLV and WMV silently stopped qualifying for the remux-first fast path and quietly fell back to a full wasm transcode — the failure mode being that everything still worked, just several times slower, on exactly the formats the optimisation existed for.
That's the shape of bug that survives code review indefinitely. No exception, no red test, no error rate. Just a fast path that stopped being taken.
If you're building this
Use WebCodecs when you can. The speed difference isn't marginal and it grows with file length.
But budget for the fallback properly, rather than treating it as a rare edge. Containers WebCodecs can't parse, codecs it can't decode (AudioDecoder has no AC-3, MP2 or WMA), and browsers that don't ship it are collectively common enough that the fallback is a feature, not a safety net.
Spend the effort on routing rather than on picking a winner. The two paths that are neither engine are where a meaningful share of the wins are, and neither shows up if you've framed the problem as a choice between two encoders.
And test in a browser. Both bugs — the loud one and the silent one — were found by running real files through real Chromium and inspecting what came out. Neither broke a unit test.
Measurements from VideoCompress, a browser-based video compressor — the files never leave the tab, which is why all of this had to work client-side in the first place.
Top comments (1)
Author here — two things I left out of the post.
The 305 vs 60 Mpx/s spread is the part I'd most like others to check. If you have a machine without hardware AVC, does 60 hold? Our sample is small, and the router's shipped figure of 150 is essentially a bet on that distribution being roughly bimodal. If it turns out to be a smooth spread instead, splitting the difference is the wrong strategy entirely.
Second: we're on the single-threaded ffmpeg core because the multithreaded build needs SharedArrayBuffer → cross-origin isolation → which breaks the AdSense script. If anyone has shipped COOP/COEP alongside third-party embeds without giving something up, I'd genuinely like to hear how.