Why the Images API needed a binding

Cloudflare Images already handles the heavy lifting of media pipelines: store one original and generate resized, manipulated, or re-encoded variants on demand. But the API's reliance on fetch() introduced friction for developers building full-stack apps where images don't always live at a URL.

Three specific pain points stood out. First, the original image had to be retrievable from a URL, which ruled out transforming images at upload time from a local client. Second, optimization and delivery were coupled: applying multiple transformations in sequence meant serving an intermediate result, grabbing its output URL, and transforming again. Third, optimization parameters followed a fixed hierarchy — cropping always preceded resizing — making alternative orders hard to express without awkward workarounds.

To address these, Cloudflare released the Images binding for Workers in February 2025. The binding connects the Images API directly to Worker code, enabling programmatic workflows that transform, overlay, and encode images without URL-based round trips.

How the binding changes the workflow

Bindings are the standard mechanism for connecting Workers to external Developer Platform resources. The Images binding exposes four functions inside a Worker:

  • .transform() — applies optimization parameters to an image
  • .draw() — overlays an image, which itself can be processed via a nested transform()
  • .output() — declares the final output format
  • .info() — returns metadata about the original image, including format, size, and dimensions

Because transformations are now separate operations rather than a single delivery request, developers can compose them freely. There is no need to persist and re-fetch intermediate versions, and operations no longer follow a fixed order.

Behind the request lifecycle

When a Worker is deployed with wrangler deploy, the runtime constructs a dependency graph from the invoked bindings. That graph describes the objects to inject into the Worker's env. For Images, the binding is a JavaScript wrapper that issues HTTP calls to the Images API backend.

Each .transform() call appends a node to an operation tree. A .draw() call adds a subtree describing how to build the overlay. When .output() executes, the tree is flattened into a list of operations and transmitted to the backend along with the input image.

The team considered binary formats for these requests but settled on multipart forms. Each request is inherently expensive because it involves decoding, transforming, and encoding an image; the overhead difference between binary and multipart formats was nominal relative to that processing cost. Multipart forms also offered a well-supported, proven approach.

Local development without trade-offs

Testing and debugging were central to the binding's design, since developers won't adopt a tool they can't validate locally. Cloudflare explored forwarding local requests to production backend services but found that would require open-sourcing binding components and building them for every Wrangler-supported platform and Node version.

Instead, Wrangler offers two modes. Online mode, the default for wrangler dev, makes real requests to the Images API and mirrors the production behavior exactly — though it requires internet access and Cloudflare API authentication. Offline mode uses a low-fidelity fake, a mock API implementation supporting a limited feature subset. This mode is best for unit tests via the Vitest integration, needing neither internet nor credentials, and incurs no usage charges.

Example: watermarking uploads into R2

Consider an app that transforms a user-uploaded image and stores the result in an R2 bucket. Configure the bindings in wrangler.toml:

[images]
binding = "IMAGES"

[[r2_buckets]]
binding = "R2"
bucket_name = "<BUCKET>"

[assets]
directory = "./<DIRECTORY>"
binding = "ASSETS"

The frontend presents a <form> that accepts image uploads:

const html = `
<!DOCTYPE html>
        <html>
          <head>
            <meta charset="UTF-8">
            <title>Upload Image</title>
          </head>
          <body>
            <h1>Upload an image</h1>
            <form method="POST" enctype="multipart/form-data">
              <input type="file" name="image" accept="image/*" required />
              <button type="submit">Upload</button>
            </form>
          </body>
        </html>
`;

export default {
  async fetch(request, env) {
    if (request.method === "GET") {
      return new Response(html, {headers:{'Content-Type':'text/html'},})
    }
    if (request.method ==="POST") {
      // This is called when the user submits the form
    }
  }
};

Since the image arrives from the browser, there is no URL to fetch(). The Worker can read the uploaded bytes directly, apply the transform, encode to AVIF, and write to R2:

var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });

function assetUrl(request, path) {
	const url = new URL(request.url);
	url.pathname = path;
	return url;
}
__name(assetUrl, "assetUrl");

export default {
  async fetch(request, env) {
    if (request.method === "GET") {
      return new Response(html, {headers:{'Content-Type':'text/html'},})
    }
    if (request.method === "POST") {
      try {
        // Parse form data
        const formData = await request.formData();
        const file = formData.get("image");
        if (!file || typeof file.arrayBuffer !== "function") {
          return new Response("No image file provided", { status: 400 });
        }
        
        // Read uploaded image as array buffer
        const fileBuffer = await file.arrayBuffer();

	     // Fetch image as watermark
        let watermarkStream = (await env.ASSETS.fetch(assetUrl(request, "watermark.png"))).body;

        // Apply watermark and convert to AVIF
        const imageResponse = (
          await env.IMAGES.input(fileBuffer)
              // Draw the watermark on top of the image
              .draw(
                env.IMAGES.input(watermarkStream)
                  .transform({ width: 100, height: 100 }),
                { bottom: 10, right: 10, opacity: 0.75 }
              )
              // Output the final image as AVIF
              .output({ format: "image/avif" })
          ).response();

          // Add timestamp to file name
          const fileName = `image-${Date.now()}.avif`;
          
          // Upload to R2
          await env.R2.put(fileName, imageResponse.body)
         
          return new Response(`Image uploaded successfully as ${fileName}`, { status: 200 });
      } catch (err) {
        console.log(err.message)
      }
    }
  }
};

The documentation gallery includes more patterns, such as transcoding images from Workers AI or drawing a watermark from KV onto an image stored in R2.