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.1POST /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.webpGET /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.avifPOST /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.jpgPOST /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- Unknownformat(INVALID_FORMAT),qualitynot 1–100 (INVALID_QUALITY),width/heightout of range orfitwithout either (INVALID_DIMENSIONS), unknownfit(INVALID_FIT), missing or invalidurl/watermarkUrl(INVALID_URL), empty body (MISSING_IMAGE)413- Image over 10 MB (PAYLOAD_TOO_LARGE)415- Notimage/*, 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 devuses a low-fidelity local Images implementation (resize, rotate, format only); use--remoteto 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.
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
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.webpLocal Development
cd apps/experiments/image-converter
npm install
npm run devcurl -X POST "http://localhost:8787/info" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpgConfiguration
wrangler.json declares:
imagesbindingIMAGES(typed asImagesBinding)
Cloudflare Features Used
- Workers - Edge compute runtime
- Images binding -
input(),transform(),draw(),output(), andinfo()on raw image bytes