Why ffmpeg.wasm dies inside a Cloudflare Worker
Read why ffmpeg.wasm can't run in a Cloudflare Worker. The 31 MB core blows the 10 MB bundle cap, and workerd blocks runtime WASM compilation.
I spent an afternoon trying to run ffmpeg.wasm inside a Cloudflare Worker. It died with CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedder before it touched a single frame of video.
Short version, so you can leave if this is all you needed. You can’t run ffmpeg.wasm in a standard Cloudflare Worker, and no amount of config fixes it. The Worker runtime blocks the one thing ffmpeg.wasm does at startup, which is compile WebAssembly from bytes it fetched at runtime. Even if it didn’t, the roughly 31 MB core won’t fit the Worker bundle, and there are no Web Workers to run it in. The thing that actually works is to stop running FFmpeg in the Worker at all.
If you want the why and the what-instead, keep reading. I went down every dead end so the layout of the wall is clear.
Why I reached for ffmpeg.wasm
A user uploads a clip. I want a WebM version and a thumbnail. The request already lands in a Worker, so the obvious move is to do the work right there and return the result.
FFmpeg is a native command-line binary, and Workers can’t run native binaries. So I reached for ffmpeg.wasm, which is FFmpeg compiled to WebAssembly. No binary, runs as wasm, and Workers run wasm. On paper it fits a runtime that has no binaries. That framing is the trap, and it took two failed attempts to see why.
Attempt 1: bundle the core into the Worker
Workers run WebAssembly, but only wasm that’s imported at build time so Wrangler can precompile it into the bundle. So the first idea is to vendor the core and import it directly.
// Workers only run WASM that's imported at build time, so// Wrangler can precompile it into the deployed bundle.import coreWasm from "./ffmpeg-core.wasm"; // you'd vendor the ~31 MB core here
const instance = await WebAssembly.instantiate(coreWasm, importObject);Two problems, and either one is fatal.
The Worker bundle is capped at 3 MB gzipped on the free plan and 10 MB on paid. The single-thread @ffmpeg/core is around 31 MB before you add the JS glue, and that figure moves with the version. It does not fit even compressed. The 10 MB paid ceiling isn’t close.
The deeper problem is that ffmpeg.wasm isn’t packaged to be loaded as a bare precompiled module. Its loader expects to fetch the core itself and wire it up at runtime. Which leads straight into attempt 2.
Attempt 2: fetch the core and compile it at runtime
This is how ffmpeg.wasm normally loads. Its toBlobURL helper fetches ffmpeg-core.js and ffmpeg-core.wasm, wraps them in Blob URLs to sidestep CORS, then the emscripten glue calls WebAssembly.instantiateStreaming (or WebAssembly.instantiate on the raw bytes as a fallback) to compile the core. In a browser this is fine. Here is the same move, stripped down to the part that matters.
export default { async fetch() { const bytes = await fetch( ).then((r) => r.arrayBuffer());
// The same compile-from-fetched-bytes move ffmpeg.wasm makes after toBlobURL(). await WebAssembly.instantiate(bytes, {});
return new Response("never gets here"); },};It never gets there. The Worker throws:
CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedderThat error is the whole article. workerd, the runtime behind Workers, only runs WebAssembly modules that were precompiled at build time. Compiling wasm from bytes you fetched at runtime is blocked, for the same reason eval() is blocked. The Cloudflare docs say it plainly: WebAssembly.instantiate() only supports pre-compiled modules.
ffmpeg.wasm’s entire loader is built on the one move the runtime forbids. There’s no flag for it. The fetch-then-compile path is the path.
The walls behind the wall
Say you patched all that. You precompiled the core, you somehow squeezed it under the bundle cap. You still don’t get a working transcode, because the rest of ffmpeg.wasm assumes a browser.
ffmpeg.wasm offloads its work to a Web Worker by default. workerd has no Web Workers and no worker_threads. There’s nothing to spawn that worker in.
The multithread core, @ffmpeg/core-mt, needs SharedArrayBuffer threads (and cross-origin isolation headers in a browser). The single-thread core doesn’t need SharedArrayBuffer, but it doesn’t help you here, because workerd has no thread substrate to share memory with in the first place.
Each Worker isolate caps at 128 MB of memory total, JavaScript heap plus WebAssembly allocations together. A ~31 MB core decompressed, plus FFmpeg’s working buffers for a real video, pushes hard against that.
And CPU time is tight by design. 10 ms on the free plan, a 30 second default on paid that you can raise to a 5 minute maximum. A genuine transcode does not respect those numbers.
Every one of these is a separate dead end. Here’s the shape of it.
| Constraint | Bundle the wasm | Fetch and compile | Offload the command |
|---|---|---|---|
| Runtime WASM compile | not needed | blocked | not needed |
| Fits the bundle | no (31 MB vs 10 MB) | not applicable | yes, nothing to bundle |
| Web Workers / threads | none | none | full |
| Memory ceiling | 128 MB | 128 MB | container RAM |
| CPU / wall budget | 30 s to 5 min | 30 s to 5 min | minutes |
| Runs native ffmpeg | no | no | yes |
The last column is the only one without a red cell, and it’s the one that doesn’t run FFmpeg in the Worker.
What about nodejs_compat?
This is the first thing people try, so it’s worth ruling out. The nodejs_compat flag gives you a curated slice of Node, things like Buffer, crypto, EventEmitter, and a virtual in-memory node:fs. It’s genuinely useful for a lot of libraries.
It doesn’t help here, because none of what FFmpeg needs is on the list. child_process exists only as a non-functional stub, so you can’t spawn the binary. There’s no worker_threads, so there’s no thread to run the multithread core in. The node:fs you get is a memory-backed virtual filesystem, not a real disk, and there’s no native ffmpeg to point it at anyway. nodejs_compat makes more npm packages import cleanly. It does not turn an isolate into a machine that can run a video encoder.
Isolate, not container
Here’s the mental model that makes all of it obvious in hindsight.
A Cloudflare Worker is a V8 isolate. Many tenants share one operating-system process, isolated from each other only at the V8 memory level. There are no syscalls you can reach, no process to spawn, no real OS filesystem (you get a virtual in-memory node:fs, not a disk). Cloudflare states the rule directly: Workers run only JavaScript and WebAssembly, never native-code binaries.
ffmpeg.wasm was an attempt to smuggle FFmpeg past that rule by shipping it as WebAssembly instead of a binary. The runtime-compile ban closes that door too. The isolate model isn’t a limitation you route around. It’s the product.
A container or micro-VM is the opposite shape. Its own process, its own Linux filesystem, the ability to exec any linux/amd64 binary. That’s where the native ffmpeg runs at full speed, with none of the wasm tax, none of the bundle math, none of the 128 MB ceiling.
So the question stops being “how do I fit FFmpeg into an isolate” and becomes “how do I get the command to a container without leaving the Worker behind.”
The fix: stop running FFmpeg in the Worker
Keep the Worker thin. Let it do what it’s genuinely fast at, which is handling the request and returning. Send the FFmpeg command to something built to run it, return immediately, and take the finished file on a webhook.
There are two honest ways to reach that container. You can run one yourself, on Cloudflare Containers, a small VM, or your own box, and own the queue, the storage, the codec builds, and the autoscaling that comes with it. Or you can hand the command to a service that already runs the FFmpeg binary and keeps all of that maintained. The first gives you full control and a standing bill. The second is one HTTPS call, which is the version I shipped.
That second path is what Rendobar’s FFmpeg API does. The Worker makes one HTTPS call and gets out of the way.
// src/index.ts (Cloudflare Worker)import { createClient } from "@rendobar/sdk";
export default { async fetch(req: Request, env: Env): Promise<Response> { const { videoUrl } = await req.json(); const rb = createClient({ apiKey: env.RENDOBAR_API_KEY });
// No binary, no wasm, no bundle. Hand off the command and return. const job = await rb.jobs.create({ type: "raw.ffmpeg", params: { command: `ffmpeg -i ${videoUrl} -c:v libvpx-vp9 -crf 30 out.webm`, }, });
return Response.json({ jobId: job.id, status: job.status }); // status: "waiting" },};The function returns in milliseconds with a job ID. The transcode runs somewhere a native binary is legal. To get the output back, register a webhook endpoint once at the account level, not per job, and each finished job arrives there.
await rb.webhooks.create({ name: "render-done", url: "https://my-app.com/api/render-done", subscribedEvents: ["job.completed"],});The Worker stays a few kilobytes. There’s nothing to precompile, nothing to fit under the bundle cap, no CPU limit to bust. Jobs bill by compute time, usually a few cents each. raw.ffmpeg has no minimum charge (the $0.02 floor only applies to captions), and every account starts with $5 in free credits and no card.
If you want the wired-up version with the webhook receiver and the runtime notes, the Cloudflare Workers setup covers it end to end, and the raw FFmpeg guide lists what commands you can send. The exact same wall shows up on Vercel (the 250 MB Lambda bundle cap) and Supabase Edge Functions (a Deno isolate, same story), and the fix is the same on all three.
What actually worked
- ffmpeg.wasm is a browser tool. It wants Web Workers, Blob URLs, and runtime wasm compilation. A Worker gives you none of those.
- Don’t try to shrink the core or precompile it. The 31 MB core, the 128 MB ceiling, and the bundle cap are all downstream of one fact. A Worker is an isolate, not a container.
- The fix isn’t a smaller FFmpeg. It’s running FFmpeg somewhere that allows a native binary, with the Worker as the thin front door.
- Submit, return, webhook. The Worker never blocks, never bundles, never busts a CPU limit.
I lost an afternoon to a CompileError so you don’t have to. If you’re staring at that exact one right now, it isn’t a setting you forgot. It’s the runtime telling you FFmpeg belongs somewhere else.
