DEV Community

Javid Jamae
Javid Jamae

Posted on • Originally published at ffmpeg-micro.com

FFmpeg on Cloudflare Workers: Why It Fails (And the Fix)

Originally published at ffmpeg-micro.com

Your FFmpeg pipeline works on localhost. You deploy to Cloudflare Workers, trigger a transcode, and the whole thing crashes before it starts. No output file. No error you can Google. Just a cold rejection from a runtime that was never going to run your binary.

This isn't a configuration problem. Cloudflare Workers are V8 isolates, not containers. FFmpeg will never run directly inside one, and no amount of bundler tweaking will change that.

Why FFmpeg won't run on Cloudflare Workers

Most developers try three approaches before giving up. All three fail for different architectural reasons.

V8 isolates can't spawn subprocesses

Cloudflare Workers don't run in containers or virtual machines. They run in V8 isolates, the same engine that powers Chrome's JavaScript execution. There's no operating system layer underneath. No filesystem. No shell.

That means no child_process.exec(), no spawn(), no way to invoke a binary. FFmpeg is a compiled binary that expects to be called as a subprocess. Workers literally cannot do this. The APIs don't exist in the runtime.

If you've used FFmpeg with Node.js before, you're probably calling it through child_process or a wrapper like fluent-ffmpeg. None of that code will even parse on Workers, let alone execute. You'll get build errors before your Worker ever deploys.

ffmpeg.wasm hits the bundle size limit

The natural next thought: compile FFmpeg to WebAssembly and run it in the Worker. That's what ffmpeg.wasm does in the browser, and Workers support WASM.

But Cloudflare enforces a 10 MB compressed bundle size limit for Workers (25 MB on the paid plan with some configurations). The ffmpeg.wasm core binary alone is roughly 25 MB uncompressed. Even aggressively stripped builds that remove codecs you don't need still land around 15-20 MB. You blow past the limit before adding a single line of your own code.

Some developers try to lazy-load the WASM binary from R2 or an external URL at runtime. That leads directly to the third problem.

workerd blocks dynamic WASM compilation

Cloudflare's runtime (workerd) does not allow dynamic WASM compilation. You can't call WebAssembly.compile() or WebAssembly.instantiate() with a buffer fetched at runtime. The WASM module must be pre-compiled and included in your Worker bundle at deploy time.

This is a deliberate security decision. Allowing arbitrary WASM compilation at runtime would break the isolation guarantees that make Workers safe to run on shared infrastructure.

So you can't fetch the ffmpeg.wasm binary from R2, compile it on the fly, and run it. The module would need to be bundled, which circles back to the size limit. There's no escape hatch here.

The fix: call an FFmpeg API from your Worker

Workers can't run FFmpeg, but they're excellent at one thing: making HTTP requests. Fast, globally distributed HTTP requests with sub-millisecond cold starts.

Instead of fighting the runtime, use the Worker for what it's good at. Accept the upload or webhook trigger, make a single fetch() call to FFmpeg Micro to handle the transcode, and return the result. Your Worker stays small, fast, and within every Cloudflare limit.

This is the same pattern that works for FFmpeg on AWS Lambda and FFmpeg on Heroku. The platform handles routing and triggers. The transcoding happens on infrastructure built for it.

A single API call replaces the entire ffmpeg.wasm experiment:

curl -X POST https://api.ffmpeg-micro.com/v1/transcodes \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inputs": [{"url": "https://your-r2-bucket.r2.dev/input.mp4"}],
    "outputFormat": "mp4",
    "options": [
      {"option": "-c:v", "argument": "libx264"},
      {"option": "-crf", "argument": "28"}
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

Or use a preset if you don't want to think about FFmpeg flags:

{
  "inputs": [{"url": "https://your-r2-bucket.r2.dev/input.mp4"}],
  "outputFormat": "mp4",
  "preset": {"quality": "high", "resolution": "1080p"}
}
Enter fullscreen mode Exit fullscreen mode

Working example: compress-on-upload Worker

This Worker receives a video URL (for example, from an R2 upload event or a webhook), sends it to FFmpeg Micro for compression, and returns the download URL for the processed file.

export interface Env {
  FFMPEG_MICRO_API_KEY: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const { videoUrl } = await request.json<{ videoUrl: string }>();

    if (!videoUrl) {
      return new Response(
        JSON.stringify({ error: "videoUrl is required" }),
        { status: 400, headers: { "Content-Type": "application/json" } }
      );
    }

    const createResponse = await fetch(
      "https://api.ffmpeg-micro.com/v1/transcodes",
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${env.FFMPEG_MICRO_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          inputs: [{ url: videoUrl }],
          outputFormat: "mp4",
          options: [
            { option: "-c:v", argument: "libx264" },
            { option: "-crf", argument: "28" },
            { option: "-preset", argument: "fast" },
          ],
        }),
      }
    );

    if (!createResponse.ok) {
      const error = await createResponse.text();
      return new Response(
        JSON.stringify({ error: "Transcode creation failed", details: error }),
        { status: 502, headers: { "Content-Type": "application/json" } }
      );
    }

    const job = await createResponse.json<{ id: string; status: string }>();

    const jobId = job.id;
    let status = job.status;
    let attempts = 0;
    const maxAttempts = 60;

    while (status !== "completed" && status !== "failed" && attempts < maxAttempts) {
      await new Promise((resolve) => setTimeout(resolve, 2000));
      const pollResponse = await fetch(
        `https://api.ffmpeg-micro.com/v1/transcodes/${jobId}`,
        {
          headers: {
            Authorization: `Bearer ${env.FFMPEG_MICRO_API_KEY}`,
          },
        }
      );
      const pollData = await pollResponse.json<{ status: string }>();
      status = pollData.status;
      attempts++;
    }

    if (status === "failed") {
      return new Response(
        JSON.stringify({ error: "Transcode failed", jobId }),
        { status: 500, headers: { "Content-Type": "application/json" } }
      );
    }

    if (status !== "completed") {
      return new Response(
        JSON.stringify({ error: "Transcode timed out", jobId }),
        { status: 504, headers: { "Content-Type": "application/json" } }
      );
    }

    const downloadResponse = await fetch(
      `https://api.ffmpeg-micro.com/v1/transcodes/${jobId}/download?url=true`,
      {
        headers: {
          Authorization: `Bearer ${env.FFMPEG_MICRO_API_KEY}`,
        },
      }
    );

    const downloadData = await downloadResponse.json<{ url: string }>();

    return new Response(
      JSON.stringify({
        status: "completed",
        jobId,
        downloadUrl: downloadData.url,
      }),
      { status: 200, headers: { "Content-Type": "application/json" } }
    );
  },
};
Enter fullscreen mode Exit fullscreen mode

Set your API key as a Workers secret:

npx wrangler secret put FFMPEG_MICRO_API_KEY
Enter fullscreen mode Exit fullscreen mode

Then deploy:

npx wrangler deploy
Enter fullscreen mode Exit fullscreen mode

Your Worker stays under 1 MB. The video processing runs on dedicated infrastructure with no bundle limits, no WASM restrictions, and no subprocess constraints.

Common pitfalls

Polling too aggressively. The example above polls every 2 seconds, which is fine for most video lengths. For large files (over 1 GB), increase the interval to 5-10 seconds to avoid unnecessary API calls.

Hitting the Worker CPU time limit. Free-tier Workers have a 10ms CPU time limit per request. The polling loop uses wall-clock time (waiting on fetch), not CPU time, so it won't trigger this limit. But if you add heavy request parsing or data transformation, you could hit it. Paid plans get 30 seconds of CPU time.

Forgetting the Worker duration limit. Workers on the free plan can run for up to 30 seconds total. The polling loop in the example could exceed this for longer videos. On the paid plan ($5/month), Workers can run for up to 30 minutes, which covers most transcoding jobs. For very long encodes, consider using a Cloudflare Queue or Durable Object to handle the polling asynchronously.

Passing R2 private URLs. If your video is in a private R2 bucket, the FFmpeg Micro API can't fetch it directly. Generate a presigned R2 URL with a short expiry and pass that as the input URL instead.

You can get an FFmpeg Micro API key in under a minute at ffmpeg-micro.com.

FAQ

Can I run FFmpeg directly on Cloudflare Workers?

No. Cloudflare Workers run in V8 isolates, not containers. There's no subprocess execution, no filesystem, and no way to invoke compiled binaries. FFmpeg requires all three. The only way to process video from a Worker is to call an external API like FFmpeg Micro.

Will ffmpeg.wasm work on Cloudflare Workers?

No. The ffmpeg.wasm core binary exceeds Cloudflare's 10 MB compressed bundle size limit, and the Workers runtime (workerd) blocks dynamic WASM compilation at runtime. You can't bundle it and you can't load it on the fly. Both paths are dead ends.

What is the Cloudflare Workers bundle size limit?

Cloudflare enforces a 10 MB compressed limit for Worker bundles on the free plan and most paid configurations. Some enterprise plans allow up to 25 MB. The ffmpeg.wasm binary alone is roughly 25 MB uncompressed, which exceeds both tiers after you add your application code.

How do I process video uploads stored in Cloudflare R2?

Generate a presigned URL for the R2 object and pass it to the FFmpeg Micro API as the input URL. Your Worker handles the R2 presigning and the API call. The transcode runs externally, and you get back a download URL for the processed file. Your R2 object never needs to be publicly accessible.

Is there a Cloudflare-native alternative to FFmpeg for video processing?

Cloudflare Stream handles video hosting and adaptive bitrate delivery, but it's not a general-purpose transcoding tool. You can't pass custom FFmpeg flags, extract audio tracks, generate thumbnails at specific timestamps, or apply filters like text overlays and cropping. For anything beyond basic video hosting, you need an FFmpeg API.

Top comments (0)