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.

Rendobar 10 MIN READ
Share

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 here
const 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 embedder

That 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.

ConstraintBundle the wasmFetch and compileOffload the command
Runtime WASM compilenot neededblockednot needed
Fits the bundleno (9.7 MiB gz fills paid, triples free)not applicableyes, nothing to bundle
Web Workers / threadsnonenonefull
Memory ceiling128 MB128 MBgigabytes
CPU / wall budget30 s to 5 min30 s to 5 minminutes
Runs native ffmpegnonoyes

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.

CLOUDFLARE WORKERV8 isolateone OS process, many tenantsyouno native binaryno real disk, no /tmpno threads, no Web Workers128 MB memory ceilingruns JS and WASM onlysend cmdWHERE FFMPEG IS LEGALContainer or micro-VMits own process and Linux filesystemffmpegexec any linux/amd64 binaryreal disk, gigabytes of RAMruns for minutes, full CPUnative encoder, full speedno bundle, no wasm tax, no 128 MB wall
The isolate is the product, not a limitation. FFmpeg needs the shape on the right.

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.

Your Workerthin front doorPOST /jobs~300 msRendobar APIqueues the jobreturns jobId, Worker is doneEXECUTOR, NATIVE FFMPEG, OFF THE WORKERdownloadffmpeg ~1.7suploadwebhookYour appThe Worker spends about 300 ms handing off, then returns. The encode and the wait live off the Worker.The finished file arrives on a webhook seconds later. The Worker never blocks on the transcode.
The Worker hands off in about 300 ms and returns. The encode and the wait live off the Worker, and the result comes back on a webhook.

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 itFFmpeg runs asWhat you operateCold startCost shapeBest when
Worker + offload (Rendobar’s FFmpeg API)native, on managed executorsnothing, one HTTPS callsecondsper second of compute, about $0.0025 on my test clip, $5 free creditwork is spiky and a webhook is all you want
Self-run Cloudflare Containersnative, in your containerimage, queue, storage, scaling1 to 3 svCPU and memory billed per 10 ms active, 375 vCPU-minutes free monthlyyou’re all-in on Cloudflare and want to own the box
AWS Lambda or Vercelnative, via a static binary or layerthe function, the binary, storagesub-second to secondsper GB-second, 15 min cap on Lambda, 5 to 13 min on Vercelyour stack already lives there
Your own VPS or boxnative, on the metalthe whole machinenone, always onflat monthly, about $5 and upload is steady and you like owning it
Browser Rendering APIffmpeg.wasm in headless Chromea browser session per jobhundreds of ms to secondsbrowser-hours plus concurrencyalmost 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.

Tags #ffmpeg#cloudflare-workers#webassembly#serverless
All posts
Share
  1. Best FFmpeg API in 2026, compared Guides for the video API