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:
- @cf-wasm/photon — A Cloudflare Workers fork of Photon (Rust-based). Handles JPG, PNG, and WebP decode/encode. Auto-loads its WASM module when imported via the
@cf-wasm/photon/workerdentry point. - @jsquash/avif — AVIF encode and decode based on libavif compiled to WASM. Requires manual WASM initialization because Workers can't fetch WASM at runtime — the
.wasmfiles must be imported directly so wrangler can bundle them.
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:
- File size gate — Reject files larger than 10MB before any processing begins.
- 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:
| Component | Memory |
|---|---|
| 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:
- Non-AVIF → Non-AVIF (e.g., JPG → WebP) — Photon decodes and Photon encodes. The simplest path.
- Non-AVIF → AVIF (e.g., PNG → AVIF) — Photon decodes to a PhotonImage, we extract raw RGBA pixels, wrap them in an ImageData object, and pass to jSquash's AVIF encoder.
- AVIF → Non-AVIF (e.g., AVIF → JPG) — jSquash decodes AVIF to ImageData, we construct a new PhotonImage from the RGBA pixel data, and Photon encodes to the target format.
- AVIF → AVIF (re-compress) — jSquash decodes, then jSquash encodes at the requested quality level.
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:
| Conversion | Typical Time |
|---|---|
| JPG → PNG | 100–300ms |
| PNG → WebP | 200–500ms |
| JPG → AVIF | 500ms–2s |
| AVIF → JPG | 300ms–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 APIFrequently 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.
