Clapr processes video inside your browser tab. No upload, no queue, no copy of your file on someone's disk. That sentence is easy to write on a landing page and considerably more annoying to ship.
This is a write-up of what it actually cost, including two decisions that turned out to be wrong and one bug they quietly produced. The numbers are from our own deployment, not from a tutorial.
The 32MB problem
FFmpeg compiled to WebAssembly is not small. The core we ship is 32,232,419 bytes — about 30.7 MiB of .wasm, plus roughly 110KB of JavaScript glue.
That is the entry fee. Before a user can compress a single video, the browser downloads thirty megabytes. There is no clever way around it: the thing doing the work is a real media framework, and a real media framework is large.
What you can control is when it is paid. Three things matter:
Load lazily, never at page load. The core is fetched on the first actual job, not when the page renders. A visitor who reads the page and leaves downloads nothing. This is also why it must not be bundled — it lives in public/ and is fetched at runtime.
Self-host it. The obvious shortcut is to pull the core from a public CDN. We serve it from our own origin instead. Partly it is reliability, but mostly it is that fetching the core from a third party hands that third party a request log correlating with "someone is about to process a video" — which sits badly next to a privacy claim.
Show byte-level progress. A 30MB download behind a spinner reads as a hung page. We stream the response and surface loaded/total bytes to the UI, so the first thing a user sees is a number moving. The perceived cost of a large download is mostly a function of whether anything appears to be happening.
The multi-threaded core we gave up on
FFmpeg's WebAssembly build comes in two flavours: single-threaded, and a multi-threaded build that uses SharedArrayBuffer and Emscripten's pthread support. Multi-threaded is meaningfully faster at encoding. We tried it first, and we shipped the single-threaded one.
The reason is specific and not widely written up. We load the core through a blob URL, which is what lets us stream the download and report progress. Under that loading strategy, the multi-threaded build's Emscripten pthread pool hangs — and it hangs in the worst possible way: worker.onerror fires, but the promise never rejects.
So there is no error to catch and nothing to fall back to. The tool simply stops, forever, with a progress bar that never moves. A failure that throws is a bug you fix in an afternoon. A failure that hangs silently is one you lose a day to before you even accept where it is happening.
We chose the single-threaded core. Encoding is slower. Nothing hangs.
The isolation headers we did not need
Multi-threaded WebAssembly needs SharedArrayBuffer, and SharedArrayBuffer needs the page to be cross-origin isolated:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentiallessThese are not free. COEP changes how every cross-origin subresource on the page loads. Turn it on site-wide and you find out which of your embeds, fonts, and analytics scripts were quietly relying on credentialed cross-origin requests.
Here is the part worth taking away: once we moved to the single-threaded core, we did not need any of it. No SharedArrayBuffer, no COOP, no COEP. The headers stayed on the tool routes out of inertia.
There is also a real trap in how you set them. Setting the same header from two places — say, framework config and middleware — results in the header being written twice. Browsers seeing two conflicting COOP values fall back to unsafe-none, which silently disables cross-origin isolation and hides SharedArrayBuffer again. If you are debugging "isolation is on but crossOriginIsolated is false," count your headers before you doubt your logic. Set them in exactly one place.
The bug this left behind
This is the part we would rather not publish, and it is the most useful part.
When we switched from the multi-threaded core to the single-threaded one, we changed the loader. We did not change the router.
The routing layer still decided, before every job, whether the browser was capable of local processing — and two of its checks were hasSharedArrayBuffer and crossOriginIsolated. If either was false, it routed to server-side processing. Server-side processing is not wired up. So the tool did not fall back; it showed an error and stopped.
The full chain:
- The engine no longer needs
SharedArrayBuffer. - The router still demanded it.
- Failing that demand routed to a backend that does not exist.
- Which surfaced to the user as a dead end.
Anyone whose browser did not expose isolation — corporate proxies, some embedded webviews, any page where the headers did not apply — was refused by a tool that would have worked fine.
The failure mode is worth naming because it generalises: a capability check that outlives the capability it was checking for. It does not throw, no test fails, and it only affects users whose environment differs from yours. We found it by auditing the path rather than by anything reporting it, which is the uncomfortable part — the users who hit it just left.
The fix was to delete both checks. The memory-ceiling and mobile-file-size checks stayed, because those constrain the engine we actually run.
What is genuinely hard about browser processing
Setting aside our own mistakes, three constraints are structural:
Memory is the real ceiling, not file size. A browser tab has a memory budget well below the machine's RAM, and decoding video is memory-hungry. We estimate peak usage from input size and refuse jobs projected to exceed roughly 3.5GB on desktop and 1.5GB on mobile. Refusing up front is far better than being killed mid-encode after four minutes.
Mobile is a different machine. Same code, a fraction of the memory headroom and thermal budget. We cap mobile jobs at a lower file size, because "it crashed at 80%" is a worse outcome than "this one is too big for your phone."
You are using the user's CPU. A cloud service with a GPU will beat this on speed, always. What browser processing wins on is that nothing is uploaded, nothing is queued, and nothing is retained. Those are different axes, and pretending it wins on speed sets people up to be disappointed.
Try it
The thing described above, doing the thing it describes:
Try it right here
Runs in your browser. Your file is not uploaded anywhere.
First run downloads the core; subsequent runs in the same tab reuse it. Watch the network panel if you want to confirm nothing leaves your machine.
FAQ
How large is the FFmpeg WebAssembly core?
The single-threaded build we ship is 32,232,419 bytes of .wasm plus about 110KB of JavaScript. It is downloaded on first use, not at page load.
Do I need COOP/COEP headers for ffmpeg.wasm?
Only for the multi-threaded build, which needs SharedArrayBuffer. The single-threaded core requires neither, which removes a whole category of deployment problems.
Why is the multi-threaded core hanging?
If you are loading it via a blob URL, the Emscripten pthread pool can hang without rejecting the load promise — worker.onerror fires but nothing propagates. You get a permanent pending state rather than an error.
Why is crossOriginIsolated false when I set the headers?
Check whether the headers are being set from more than one place. Duplicated COOP headers cause browsers to fall back to unsafe-none. Set them once.
Is this faster than a server-side converter? No. A server with a GPU is faster. The trade is that your file is never uploaded, never queued behind other users, and never stored.
Figures measured from this project's deployment on 16 September 2026. Other tools: compress video, extract audio, or all Clapr tools.