# Why ffmpeg.wasm can't run in a Cloudflare Worker

Canonical: https://rendobar.com/blog/ffmpeg-wasm-cloudflare-workers/
Author: Abdelrahman Essawy
Published: 2026-06-01
Updated: 2026-06-07

---

## Key takeaways

- ffmpeg.wasm is a browser tool. It cannot run inside a standard Cloudflare Worker.
- workerd blocks runtime WebAssembly compilation, so the loader throws a CompileError at startup.
- Gzipped the core is about 9.7 MiB, which fills the entire 10 MB paid bundle and is triple the 3 MB free cap.
- The fix is to run FFmpeg outside the Worker, in a container, a function with a native binary, or an offload service.

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](https://ffmpegwasm.netlify.app/), 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.

```ts
// 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.

```ts
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:

```text
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](https://developers.cloudflare.com/workers/runtime-apis/webassembly/) 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](https://github.com/ffmpegwasm/ffmpeg.wasm/discussions/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](https://developers.cloudflare.com/browser-rendering/) 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](https://developers.cloudflare.com/workers/reference/security-model/). 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.

_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](/ffmpeg/) does. The Worker makes one call and gets out of the way.

```ts
// 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.

```ts
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 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](/pricing/) and no card.

For the wired-up version with the webhook receiver, the [Cloudflare Workers setup](/ffmpeg/cloudflare/) covers it end to end, and the [FFmpeg guide](/docs/jobs/ffmpeg) lists the commands you can send. The same wall shows up on [Vercel](/ffmpeg/vercel/) and [Supabase Edge Functions](/ffmpeg/supabase/), 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.
