Why ffmpeg.wasm can't run in a Cloudflare Worker
Read why ffmpeg.wasm can't run in a standard Cloudflare Worker. workerd blocks runtime WASM compilation, and the 31 MB core barely survives gzip.
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 that’s all you needed. You can’t run ffmpeg.wasm in a standard Cloudflare Worker, and no config fixes it. The 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 31 MB core leaves no room in the bundle, and there are no Web Workers to run it in. The thing that works is to stop running FFmpeg in the Worker.
Below is every dead end, with measured numbers instead of the round ones everyone quotes.
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 there and return the result.
FFmpeg is a native binary, and Workers can’t run native binaries. So I reached for ffmpeg.wasm, FFmpeg compiled to WebAssembly. No binary, runs as wasm, and Workers run wasm. On paper it fits a runtime with no binaries. That framing is the trap.
Attempt 1: bundle the core into the Worker
Workers run WebAssembly, but only modules imported at build time, so Wrangler can precompile them into the bundle. First idea: 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 core hereconst instance = await WebAssembly.instantiate(coreWasm, importObject);The bundle is capped after compression: 3 MB on free, 10 MB on paid, with a 64 MB ceiling before compression. So I measured the current single-thread @ffmpeg/core@0.12.10 off the CDN instead of trusting the “31 MB” everyone quotes. The wasm is 32,232,419 bytes, which is 30.7 MiB raw and 9.75 MiB gzipped, plus 110 KB of JS glue.
Raw size isn’t the wall. The 64 MB uncompressed limit clears it. Compression is the wall, and it cuts differently per plan. On free, 9.75 MiB is more than triple the 3 MB cap. Hopeless. On paid, it lands right on the 10 MB line: the core alone fills the budget, with nothing left for your own code or the glue. You blow the cap the moment you add anything real.
So bundling is dead on free and a non-starter on paid. Even if you squeezed under, the loader still won’t run, because ffmpeg.wasm doesn’t load as a bare precompiled module. It fetches the core and wires it up at runtime. Which is attempt 2.
Attempt 2: fetch the core and compile it at runtime
This is how ffmpeg.wasm normally loads. Its toBlobURL helper fetches the core, wraps it in Blob URLs to dodge CORS, then the emscripten glue calls WebAssembly.instantiateStreaming (or instantiate on the raw bytes) to compile it. Fine in a browser. Here is the part that matters.
export default { async fetch() { const bytes = await fetch( "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm/ffmpeg-core.wasm", ).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 precompiled at build time. Compiling wasm from fetched bytes is blocked, for the same reason eval() is. The Cloudflare docs say it plainly: WebAssembly.instantiate() only supports pre-compiled modules. ffmpeg.wasm’s loader is built on the one move the runtime forbids, and there’s no flag for it.
This isn’t a corner I hit alone. The ffmpeg.wasm maintainers have an open discussion, #782, titled “How to run ffmpeg.wasm directly on the main thread (usecase: cloudflare worker, browser extension service worker, etc)”. Open since 2023, last poked in September 2025, still unanswered. The maintainer’s word for it is “on the back burner.” Prisma hit the same wall from the other side: Prisma 7 throws the identical Wasm code generation disallowed by embedder on Workers where Prisma 6 didn’t.
The walls behind the wall
Say you patched all that, precompiled the core, somehow squeezed under the cap. You still don’t get a transcode, because the rest of ffmpeg.wasm assumes a browser.
It offloads work to a Web Worker by default, and workerd has no Web Workers or worker_threads. The multithread core, @ffmpeg/core-mt, needs SharedArrayBuffer threads, and there’s no substrate to share memory with. Each isolate caps at 128 MB total, JavaScript heap plus WebAssembly together, and a 30 MB core decompressed plus FFmpeg’s buffers for real video pushes hard against that. CPU time is tight by design too: 10 ms on free, a 30 second default on paid up to a 5 minute max. A genuine transcode respects none of these.
| Constraint | Bundle the wasm | Fetch and compile | Offload the command |
|---|---|---|---|
| Runtime WASM compile | not needed | blocked | not needed |
| Fits the bundle | no (9.7 MiB gz fills paid, triples free) | not applicable | yes, nothing to bundle |
| Web Workers / threads | none | none | full |
| Memory ceiling | 128 MB | 128 MB | gigabytes |
| 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?
The first thing people try. It gives you a slice of Node: Buffer, crypto, EventEmitter, a virtual in-memory node:fs. None of it helps here. child_process is a non-functional stub, so you can’t spawn the binary. There’s no working worker_threads for the multithread core. The node:fs is memory-backed, not a disk, and there’s no native ffmpeg to point it at anyway. The flag makes more npm packages import cleanly. It doesn’t turn an isolate into a machine that runs a video encoder.
What about Browser Rendering?
There is one corner of Cloudflare where ffmpeg.wasm does run, worth naming so the claim stays exact. The Browser Rendering API spins up a real headless Chrome, which has everything ffmpeg.wasm wants. So yes, technically it runs there.
You shouldn’t. Browser Rendering is for screenshots, PDFs, and scraping, not compute. Every job pays for a full browser, 50 to 150 MB of RAM before you decode a frame, and ffmpeg.wasm runs at 10 to 20 percent of native speed. A transcode a native binary finishes in 30 seconds takes wasm 5 to 10 minutes. You’d rent browser-hours to run an encoder at a fifth of its speed inside a tab. The headline holds.
Isolate, not container
Here is the mental model that makes it obvious. A Cloudflare Worker is a V8 isolate. Many tenants share one OS process, isolated only at the V8 memory level. No syscalls, no process to spawn, no real filesystem. Cloudflare’s rule is explicit: Workers run JavaScript and WebAssembly, never native binaries.
ffmpeg.wasm tried to smuggle FFmpeg past that rule as wasm instead of a binary. The runtime-compile ban closes that door too. The isolate model isn’t a limit to route around. It’s the product.
A container is the opposite shape: its own process, its own Linux filesystem, the ability to exec any linux/amd64 binary. That’s where native ffmpeg runs at full speed, no wasm tax, no bundle math, no 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 handle the request and return. Send the FFmpeg command somewhere built to run it, return immediately, and take the finished file on a webhook.
There are two honest ways to reach that container. Run one yourself (Cloudflare Containers, a small VM, your own box) and own the queue, the storage, the codec builds, and the scaling. Or hand the command to a service that already runs FFmpeg and maintains all of it. The first is full control and a standing bill. The second is one HTTPS call, which is what I shipped.
That second path is what Rendobar’s FFmpeg API does. The Worker makes one 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: "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 hand-off is all the Worker pays for. Four warm submissions to the live API came back in 280 to 323 ms, median 298. It posts the command, gets a job ID, and it’s done. For the output, register a webhook once at the account level, not per job.
await rb.webhooks.create({ name: "render-done", url: "https://my-app.com/api/render-done", subscribedEvents: ["job.completed"],});The transcode runs where a native binary is legal. On a test clip, a 1.5 MB 480p MP4 scaled and re-encoded to H.264, the ffmpeg step took 1.7 seconds and the full pipeline (download, probe, encode, upload) about 2.3 seconds of real work, for roughly a quarter of a cent. The file lands on your webhook seconds later, low tens of seconds if a cold machine has to boot first. Either way the Worker is long gone.
The Worker stays a few kilobytes. Nothing to precompile, no bundle cap, no CPU limit to bust. Jobs bill by compute time, usually a few cents. ffmpeg has no minimum charge (the $0.02 floor is captions only), and every account starts with $5 in free credits and no card.
For the wired-up version with the webhook receiver, the Cloudflare Workers setup covers it end to end, and the FFmpeg guide lists the commands you can send. The same wall shows up on Vercel and Supabase Edge Functions, and the fix is the same on all three.
So which should you actually use?
Offloading is what I shipped, but it’s not right for everyone. The honest comparison:
| Where you run it | FFmpeg runs as | What you operate | Cold start | Cost shape | Best when |
|---|---|---|---|---|---|
| Worker + offload (Rendobar’s FFmpeg API) | native, on managed executors | nothing, one HTTPS call | seconds | per second of compute, about $0.0025 on my test clip, $5 free credit | work is spiky and a webhook is all you want |
| Self-run Cloudflare Containers | native, in your container | image, queue, storage, scaling | 1 to 3 s | vCPU and memory billed per 10 ms active, 375 vCPU-minutes free monthly | you’re all-in on Cloudflare and want to own the box |
| AWS Lambda or Vercel | native, via a static binary or layer | the function, the binary, storage | sub-second to seconds | per GB-second, 15 min cap on Lambda, 5 to 13 min on Vercel | your stack already lives there |
| Your own VPS or box | native, on the metal | the whole machine | none, always on | flat monthly, about $5 and up | load is steady and you like owning it |
| Browser Rendering API | ffmpeg.wasm in headless Chrome | a browser session per job | hundreds of ms to seconds | browser-hours plus concurrency | almost never for transcode, see above |
It comes down to load shape and how much pipeline you want to own. For steady, predictable volume, a small VPS or Cloudflare Containers running ffmpeg all day beats any per-job price once you saturate it, and you keep the codec builds. Already on AWS or Vercel? Stay there. The offload service earns its place when the work is bursty, you’d rather not babysit a queue and a bucket, and a webhook is enough. Browser Rendering is the one row I’d talk you out of for encoding.
What actually worked
- ffmpeg.wasm is a browser tool. It wants Web Workers, Blob URLs, and runtime wasm compilation. A standard Worker has none of those.
- Don’t shrink or precompile the core. The 30 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 where a native binary is legal, with the Worker as the thin front door.
- Submit, return, webhook. The hand-off cost me about 300 ms. The encode happened somewhere it was allowed.
I lost an afternoon to a CompileError so you don’t have to. If you’re staring at that exact one, it isn’t a setting you forgot. It’s the runtime telling you FFmpeg belongs somewhere else.
