This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
Edge Platform

Image Converter

Convert, resize, and watermark images to WebP, AVIF, JPEG, or PNG with the Cloudflare Images binding at the edge

Convert uploaded or remote images to WebP, AVIF, JPEG, or PNG, resize them, and overlay a watermark with the Cloudflare Images binding (env.IMAGES). Unlike Image Resizer (fetch + cf.image), the binding works on raw bytes, so images don't need a public URL and no zone-level setup is required.

Features

  • POST /convert - convert an uploaded image (max 10 MB)
  • GET /convert?url= - convert a remote image
  • POST /watermark - draw a remote PNG bottom-right at 0.6 opacity
  • POST /info - format, file size, and dimensions (free info() call)
  • Size headers - original vs output bytes and percent saved on every conversion
  • Metadata stripped - re-encoding drops EXIF GPS location and camera details

API Reference

All conversion routes (/convert, /watermark) accept these query parameters:

Prop

Type

Successful conversions return the image bytes with:

Content-Type: image/webp
X-Original-Format: image/jpeg
X-Original-Size: 47707
X-Output-Size: 4264
X-Size-Saved-Percent: 91.1

POST /convert

Request body: raw image bytes with an image/* Content-Type (max 10 MB).

curl -X POST "https://your-worker.workers.dev/convert?format=webp&quality=70&width=800" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg \
  --output photo.webp

GET /convert

Fetches a remote image (must respond with image/*, max 10 MB) and converts it.

Prop

Type

curl "https://your-worker.workers.dev/convert?url=https://example.com/photo.jpg&format=avif&width=600" \
  --output photo.avif

POST /watermark

Converts the uploaded image and draws a remote overlay 16 px from the bottom-right corner at 0.6 opacity. Use a transparent PNG smaller than the base image.

Prop

Type

curl -X POST "https://your-worker.workers.dev/watermark?watermarkUrl=https://example.com/logo.png&format=jpeg" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg \
  --output watermarked.jpg

POST /info

Returns metadata from env.IMAGES.info(). For SVG input, width and height are null.

curl -X POST "https://your-worker.workers.dev/info" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
{ "format": "image/jpeg", "fileSize": 47707, "width": 1600, "height": 1000 }

Error Codes

  • 400 - Unknown format (INVALID_FORMAT), quality not 1–100 (INVALID_QUALITY), width/height out of range or fit without either (INVALID_DIMENSIONS), unknown fit (INVALID_FIT), missing or invalid url/watermarkUrl (INVALID_URL), empty body (MISSING_IMAGE)
  • 413 - Image over 10 MB (PAYLOAD_TOO_LARGE)
  • 415 - Not image/*, or the binding can't decode it (ImagesError 9412) (UNSUPPORTED_MEDIA_TYPE)
  • 502 - Remote image or watermark fetch failed (FETCH_ERROR), Images binding transform failed (IMAGES_ERROR)

Implementation Details

The chain in src/lib/images.ts:

env.IMAGES.input(stream)
  .transform({ width, height, fit }) // only when width/height given
  .draw(overlayStream, { opacity: 0.6, bottom: 16, right: 16 }) // /watermark only
  .output({ format: "image/webp", quality });

Metadata and privacy. Re-encoding drops invisible EXIF metadata such as GPS coordinates, camera make/model, and timestamps, which makes the output safer to share. Per the Images metadata docs, WebP and PNG output always discards metadata, and JPEG output keeps only the EXIF copyright tag by default. The binding's output() has no metadata option, so that default applies. Color profiles and EXIF rotation are baked into the pixels.

Use Cases

  • Shrink photos to WebP/AVIF before storing them in R2
  • Strip GPS location from user uploads before publishing
  • Brand images with a logo watermark on upload
  • Learn the Images binding API (input, transform, draw, output, info)

Limitations

  • Images binding calls are billed as unique transformations (info() is free)
  • 10 MB input cap in this experiment (the binding itself accepts up to 20 MB)
  • Text watermarks are not exposed; only image overlays via watermarkUrl
  • Responses are not cached (Cache-Control: no-store)
  • wrangler dev uses a low-fidelity local Images implementation (resize, rotate, format only); use --remote to test watermarks

Use in your project

Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/image-converter.

DependenciesNoneBindingsImagesPlatformWorkers runtime
import {  IMAGES_ERROR_NOT_AN_IMAGE,  OUTPUT_FORMATS,  WATERMARK_MARGIN_PX,  WATERMARK_OPACITY,} from "../constants/defaults";import type { ConvertedImage, ConvertOptions, Failure, ImageInfo } from "../types/image";import { toStream } from "./input";export async function convertImage(  images: ImagesBinding,  input: Uint8Array,  options: ConvertOptions,  overlay?: Uint8Array): Promise<ConvertedImage> {  let transformer = images.input(toStream(input));  if (options.width !== undefined || options.height !== undefined) {    transformer = transformer.transform({      width: options.width,      height: options.height,      fit: options.fit,    });  }  if (overlay) {    transformer = transformer.draw(toStream(overlay), {      opacity: WATERMARK_OPACITY,      bottom: WATERMARK_MARGIN_PX,      right: WATERMARK_MARGIN_PX,    });  }  const result = await transformer.output({    format: OUTPUT_FORMATS[options.format],    // PNG is lossless; quality only applies to JPEG, WebP and AVIF.    quality: options.format === "png" ? undefined : options.quality,  });  const body = await result.response().arrayBuffer();  return {    body,    contentType: result.contentType(),    originalSize: input.byteLength,    outputSize: body.byteLength,  };}export async function getImageInfo(images: ImagesBinding, input: Uint8Array): Promise<ImageInfo> {  const info = await images.info(toStream(input));  return {    format: info.format,    fileSize: "fileSize" in info ? info.fileSize : input.byteLength,    width: "width" in info ? info.width : null,    height: "height" in info ? info.height : null,  };}/** Maps an Images binding error: code 9412 (not an image) → 415, everything else → 502. */export function toImagesFailure(e: unknown): Failure {  const code = typeof e === "object" && e !== null && "code" in e ? e.code : undefined;  if (code === IMAGES_ERROR_NOT_AN_IMAGE) {    return {      ok: false,      code: "UNSUPPORTED_MEDIA_TYPE",      message: "Input is not a supported image",      status: 415,    };  }  console.error("Images binding error", e);  return { ok: false, code: "IMAGES_ERROR", message: "Image processing failed", status: 502 };}

Deployment

Click the deploy button

Deploy to Cloudflare Workers

Images binding

wrangler.json declares "images": { "binding": "IMAGES" }. Images must be available on your account. See Images pricing.

Test your deployment

curl -X POST "https://your-worker.workers.dev/convert?format=webp" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg \
  --output photo.webp

Local Development

cd apps/experiments/image-converter
npm install
npm run dev
curl -X POST "http://localhost:8787/info" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg

Configuration

wrangler.json declares:

  • images binding IMAGES (typed as ImagesBinding)

Cloudflare Features Used

  • Workers - Edge compute runtime
  • Images binding - input(), transform(), draw(), output(), and info() on raw image bytes

Next Steps

On this page