Go Pro
Pricing

Image Conversion on Cloudflare Workers

How we built server-side AVIF, JPG, PNG, and WebP conversion using WASM

The Challenge

Cloudflare Workers run in V8 isolates — not containers, not VMs. You can't install system binaries or use native Node.js addons. That rules out Sharp (which needs libvips), ImageMagick, and most traditional image processing libraries.

We needed to convert between AVIF, JPG, PNG, and WebP entirely within the Worker runtime. The only viable path: WebAssembly.

Why Cloudflare Workers

Workers run on Cloudflare's edge network — over 300 locations worldwide. Every API request is handled by the nearest data center, giving users sub-100ms latency regardless of where they are. Cold starts are typically under 5ms (compared to seconds for Lambda or Cloud Functions).

The trade-off is the constrained runtime. No filesystem, no native code, 128MB memory. But for a focused task like image conversion, these constraints are manageable.

The WASM Approach

We use two WASM libraries that cover all four formats:

Together they cover all 16 input/output format combinations: any of AVIF, JPG, PNG, WebP to any of AVIF, JPG, PNG, WebP.

Memory Constraints

The 128MB per-isolate limit is the biggest engineering challenge. A compressed image file can decompress to many times its size in raw RGBA pixels. A 10MB PNG screenshot (10:1 compression ratio) becomes ~100MB of decoded pixel data — dangerously close to the limit.

We use a two-gate validation strategy to prevent out-of-memory crashes:

  1. File size gate — Reject files larger than 10MB before any processing begins.
  2. Pixel dimension gate — Read the image header bytes (PNG IHDR chunk, JPEG SOF marker, WebP VP8 header, AVIF ispe box) to extract dimensions without decoding the full image. Reject if width × height exceeds 10 megapixels.

This handles both "big file" and "small file with huge dimensions" edge cases. The memory budget breaks down roughly as:

ComponentMemory
WASM modules (Photon + jSquash)~10MB
V8 runtime + Worker overhead~20MB
Decoded RGBA pixels (10MP × 4 bytes)~39MB
Encoder workspace (~1.5× decoded)~59MB
Total~128MB

The ImageData Polyfill

The workerd runtime has no Canvas or DOM APIs. The browser's ImageData constructor — which jSquash depends on — simply doesn't exist. We polyfill it at the top of our conversion module:

if (typeof globalThis.ImageData === "undefined") {
  globalThis.ImageData = class ImageData {
    constructor(dataOrWidth, widthOrHeight, height) {
      if (dataOrWidth instanceof Uint8ClampedArray) {
        this.data = dataOrWidth;
        this.width = widthOrHeight;
        this.height = height;
      } else {
        this.width = dataOrWidth;
        this.height = widthOrHeight;
        this.data = new Uint8ClampedArray(
          this.width * this.height * 4
        );
      }
    }
  };
}

This provides just enough of the ImageData interface for jSquash's AVIF codec to work — storing pixel data as a flat Uint8ClampedArray with width and height metadata.

Conversion Pipeline

The conversion routes through four codec paths depending on the input and output formats:

Input format detection uses magic bytes — the first few bytes of the file header — rather than trusting the provided MIME type. This handles cases where files have wrong extensions or content types.

Memory Management

WASM memory in Cloudflare Workers requires careful management. Workers reuse isolates across requests — leaked WASM memory accumulates until an OOM crash (Error 1102). Every PhotonImage instance must be freed in a finally block:

const image = PhotonImage.new_from_byteslice(
  new Uint8Array(inputBytes)
);
try {
  // ... decode, transform, encode
  return encodedBytes;
} finally {
  image.free();
}

This pattern ensures WASM linear memory is released even if the conversion throws an error. Without it, a Worker handling hundreds of requests would eventually run out of memory and crash.

Performance

Most conversions complete well within the 30-second CPU time budget that Workers Unbound provides. Typical timings for a 2MB input image:

ConversionTypical Time
JPG → PNG100–300ms
PNG → WebP200–500ms
JPG → AVIF500ms–2s
AVIF → JPG300ms–1s
AVIF → AVIF (recompress)800ms–3s

AVIF encoding is the slowest operation because the AV1 codec is computationally intensive. For latency-sensitive applications, converting to WebP instead of AVIF can cut response times in half.

Want to try the conversion API? Check our full API documentation or explore the 18 browser-based tools that use the same technology.

API DocsLearn About the API

Frequently Asked Questions

No. Sharp requires native Node.js bindings (libvips) and ImageMagick requires system-level binaries. Neither can run in the V8 isolate sandbox that Cloudflare Workers uses. WASM-based libraries like Photon and jSquash are the solution.

Each Worker isolate has a 128MB memory limit that includes the V8 runtime, WASM modules, and your image data. After accounting for overhead, roughly 98MB is available for image processing — enough for images up to about 10 megapixels.

Most conversions complete in 200ms–3s depending on the image size and format pair. AVIF encoding is the slowest operation. JPG/PNG/WebP conversions via Photon are typically under 500ms for typical web images.

Cloudflare Workers run on the workerd runtime which has no Canvas or DOM APIs. The jSquash AVIF codec expects ImageData objects (a browser API). The polyfill provides a minimal ImageData constructor that stores width, height, and pixel data — just enough for jSquash to work.