# Image Converter

URL: /docs/experiments/image-converter
> Convert, resize, and watermark images to WebP, AVIF, JPEG, or PNG with the Cloudflare Images binding at the edge
Tags: edge, images, webp, avif, conversion, watermark, resize
Bindings: IMAGES
Markdown: /llms.mdx/experiments/image-converter/content.md



Convert uploaded or remote images to **WebP, AVIF, JPEG, or PNG**, resize them, and overlay a watermark with the Cloudflare [Images binding](https://developers.cloudflare.com/images/transform-images/bindings/) (`env.IMAGES`). Unlike [Image Resizer](/experiments/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 [#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 [#api-reference]

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

<TypeTable
  type="{
  format: {
    description: &#x22;Output format: `webp` (default), `avif`, `jpeg` (or `jpg`), `png`&#x22;,
    type: &#x22;string&#x22;,
  },
  quality: {
    description: &#x22;Integer 1–100. Applies to JPEG, WebP, and AVIF; ignored for PNG&#x22;,
    type: &#x22;number&#x22;,
  },
  width: {
    description: &#x22;Target width in pixels (1–4096)&#x22;,
    type: &#x22;number&#x22;,
  },
  height: {
    description: &#x22;Target height in pixels (1–4096)&#x22;,
    type: &#x22;number&#x22;,
  },
  fit: {
    description:
      &#x22;`scale-down`, `contain`, `cover`, `crop`, `pad`, or `squeeze`. Requires `width` and/or `height`&#x22;,
    type: &#x22;string&#x22;,
  },
}"
/>

Successful conversions return the image bytes with:

```http
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 [#post-convert]

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

```bash
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 [#get-convert]

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

<TypeTable
  type="{
  url: {
    description: &#x22;Source image URL (`http://` or `https://`)&#x22;,
    type: &#x22;string&#x22;,
    required: true,
  },
}"
/>

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

### POST /watermark [#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.

<TypeTable
  type="{
  watermarkUrl: {
    description: &#x22;Overlay image URL (`http://` or `https://`, max 10 MB)&#x22;,
    type: &#x22;string&#x22;,
    required: true,
  },
}"
/>

```bash
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 [#post-info]

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

```bash
curl -X POST "https://your-worker.workers.dev/info" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
```

```json
{ "format": "image/jpeg", "fileSize": 47707, "width": 1600, "height": 1000 }
```

#### Error Codes [#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 [#implementation-details]

The chain in `src/lib/images.ts`:

```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](https://developers.cloudflare.com/images/transform-images/transform-via-url/#metadata), 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 [#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 [#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 [#use-in-your-project]

<UseInYourProject
  experiment="image-converter"
  bindings="[&#x22;Images&#x22;]"
  platform="[&#x22;Workers runtime&#x22;]"
  files="[
  &#x22;src/lib/images.ts&#x22;,
  &#x22;src/lib/input.ts&#x22;,
  &#x22;src/lib/params.ts&#x22;,
  &#x22;src/types/image.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/images.ts&#x22;,&#x22;title&#x22;:&#x22;images.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import {\n  IMAGES_ERROR_NOT_AN_IMAGE,\n  OUTPUT_FORMATS,\n  WATERMARK_MARGIN_PX,\n  WATERMARK_OPACITY,\n} from \&#x22;../constants/defaults\&#x22;;\nimport type { ConvertedImage, ConvertOptions, Failure, ImageInfo } from \&#x22;../types/image\&#x22;;\nimport { toStream } from \&#x22;./input\&#x22;;\n\nexport async function convertImage(\n  images: ImagesBinding,\n  input: Uint8Array,\n  options: ConvertOptions,\n  overlay?: Uint8Array\n): Promise<ConvertedImage> {\n  let transformer = images.input(toStream(input));\n  if (options.width !== undefined || options.height !== undefined) {\n    transformer = transformer.transform({\n      width: options.width,\n      height: options.height,\n      fit: options.fit,\n    });\n  }\n  if (overlay) {\n    transformer = transformer.draw(toStream(overlay), {\n      opacity: WATERMARK_OPACITY,\n      bottom: WATERMARK_MARGIN_PX,\n      right: WATERMARK_MARGIN_PX,\n    });\n  }\n\n  const result = await transformer.output({\n    format: OUTPUT_FORMATS[options.format],\n    // PNG is lossless; quality only applies to JPEG, WebP and AVIF.\n    quality: options.format === \&#x22;png\&#x22; ? undefined : options.quality,\n  });\n  const body = await result.response().arrayBuffer();\n  return {\n    body,\n    contentType: result.contentType(),\n    originalSize: input.byteLength,\n    outputSize: body.byteLength,\n  };\n}\n\nexport async function getImageInfo(images: ImagesBinding, input: Uint8Array): Promise<ImageInfo> {\n  const info = await images.info(toStream(input));\n  return {\n    format: info.format,\n    fileSize: \&#x22;fileSize\&#x22; in info ? info.fileSize : input.byteLength,\n    width: \&#x22;width\&#x22; in info ? info.width : null,\n    height: \&#x22;height\&#x22; in info ? info.height : null,\n  };\n}\n\n/** Maps an Images binding error: code 9412 (not an image) → 415, everything else → 502. */\nexport function toImagesFailure(e: unknown): Failure {\n  const code = typeof e === \&#x22;object\&#x22; && e !== null && \&#x22;code\&#x22; in e ? e.code : undefined;\n  if (code === IMAGES_ERROR_NOT_AN_IMAGE) {\n    return {\n      ok: false,\n      code: \&#x22;UNSUPPORTED_MEDIA_TYPE\&#x22;,\n      message: \&#x22;Input is not a supported image\&#x22;,\n      status: 415,\n    };\n  }\n  console.error(\&#x22;Images binding error\&#x22;, e);\n  return { ok: false, code: \&#x22;IMAGES_ERROR\&#x22;, message: \&#x22;Image processing failed\&#x22;, status: 502 };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/input.ts&#x22;,&#x22;title&#x22;:&#x22;input.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { FETCH_TIMEOUT_MS, MAX_IMAGE_BYTES } from \&#x22;../constants/defaults\&#x22;;\nimport type { ImageBytes, ParseResult } from \&#x22;../types/image\&#x22;;\n\nfunction baseContentType(header: string | null): string {\n  return (header ?? \&#x22;\&#x22;).split(\&#x22;;\&#x22;)[0].trim().toLowerCase();\n}\n\nfunction tooLarge(maxBytes: number): ParseResult<never> {\n  return {\n    ok: false,\n    code: \&#x22;PAYLOAD_TOO_LARGE\&#x22;,\n    message: `Image exceeds ${maxBytes} bytes`,\n    status: 413,\n  };\n}\n\n/** Reads an image body (incoming request or fetch response), enforcing image/* and a byte limit. */\nexport async function readImageBody(\n  source: Request | Response,\n  maxBytes: number = MAX_IMAGE_BYTES\n): Promise<ParseResult<ImageBytes>> {\n  const contentType = baseContentType(source.headers.get(\&#x22;content-type\&#x22;));\n  if (!contentType.startsWith(\&#x22;image/\&#x22;)) {\n    return {\n      ok: false,\n      code: \&#x22;UNSUPPORTED_MEDIA_TYPE\&#x22;,\n      message: `Expected an image/* content type, got \&#x22;${contentType || \&#x22;none\&#x22;}\&#x22;`,\n      status: 415,\n    };\n  }\n  const declared = Number(source.headers.get(\&#x22;content-length\&#x22;));\n  if (Number.isFinite(declared) && declared > maxBytes) return tooLarge(maxBytes);\n\n  const bytes = new Uint8Array(await source.arrayBuffer());\n  if (bytes.byteLength === 0) {\n    return { ok: false, code: \&#x22;MISSING_IMAGE\&#x22;, message: \&#x22;Image body is empty\&#x22;, status: 400 };\n  }\n  if (bytes.byteLength > maxBytes) return tooLarge(maxBytes);\n  return { ok: true, value: { bytes, contentType } };\n}\n\nexport async function fetchRemoteImage(url: string): Promise<ParseResult<ImageBytes>> {\n  let response: Response;\n  try {\n    response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });\n  } catch (e) {\n    const message = e instanceof Error ? e.message : \&#x22;Image fetch failed\&#x22;;\n    return { ok: false, code: \&#x22;FETCH_ERROR\&#x22;, message, status: 502 };\n  }\n  if (!response.ok) {\n    return {\n      ok: false,\n      code: \&#x22;FETCH_ERROR\&#x22;,\n      message: `Image fetch returned HTTP ${response.status}`,\n      status: 502,\n    };\n  }\n  return readImageBody(response);\n}\n\nexport function toStream(bytes: Uint8Array): ReadableStream<Uint8Array> {\n  return new ReadableStream<Uint8Array>({\n    start(controller) {\n      controller.enqueue(bytes);\n      controller.close();\n    },\n  });\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/params.ts&#x22;,&#x22;title&#x22;:&#x22;params.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import {\n  DEFAULT_FORMAT,\n  FIT_MODES,\n  MAX_DIMENSION,\n  MAX_QUALITY,\n  MIN_QUALITY,\n  OUTPUT_FORMATS,\n} from \&#x22;../constants/defaults\&#x22;;\nimport type { ConvertOptions, FitMode, OutputFormat, ParseResult } from \&#x22;../types/image\&#x22;;\n\ntype QueryGetter = (key: string) => string | undefined;\n\nfunction parseInteger(value: string, min: number, max: number): number | null {\n  if (!/^\\d+$/.test(value.trim())) return null;\n  const n = Number(value);\n  return n >= min && n <= max ? n : null;\n}\n\nfunction invalid(code: string, message: string): ParseResult<never> {\n  return { ok: false, code, message, status: 400 };\n}\n\nexport function isOutputFormat(value: string): value is OutputFormat {\n  return Object.hasOwn(OUTPUT_FORMATS, value);\n}\n\nexport function parseConvertOptions(query: QueryGetter): ParseResult<ConvertOptions> {\n  const rawFormat = (query(\&#x22;format\&#x22;) ?? DEFAULT_FORMAT).trim().toLowerCase();\n  const format = rawFormat === \&#x22;jpg\&#x22; ? \&#x22;jpeg\&#x22; : rawFormat;\n  if (!isOutputFormat(format)) {\n    return invalid(\n      \&#x22;INVALID_FORMAT\&#x22;,\n      `Invalid format \&#x22;${rawFormat}\&#x22;. Use one of: ${Object.keys(OUTPUT_FORMATS).join(\&#x22;, \&#x22;)}`\n    );\n  }\n  const options: ConvertOptions = { format };\n\n  const rawQuality = query(\&#x22;quality\&#x22;);\n  if (rawQuality !== undefined) {\n    const quality = parseInteger(rawQuality, MIN_QUALITY, MAX_QUALITY);\n    if (quality === null) {\n      return invalid(\&#x22;INVALID_QUALITY\&#x22;, `quality must be an integer ${MIN_QUALITY}-${MAX_QUALITY}`);\n    }\n    options.quality = quality;\n  }\n\n  for (const key of [\&#x22;width\&#x22;, \&#x22;height\&#x22;] as const) {\n    const raw = query(key);\n    if (raw === undefined) continue;\n    const value = parseInteger(raw, 1, MAX_DIMENSION);\n    if (value === null) {\n      return invalid(\&#x22;INVALID_DIMENSIONS\&#x22;, `${key} must be an integer 1-${MAX_DIMENSION}`);\n    }\n    options[key] = value;\n  }\n\n  const rawFit = query(\&#x22;fit\&#x22;);\n  if (rawFit !== undefined) {\n    if (!FIT_MODES.includes(rawFit as FitMode)) {\n      return invalid(\&#x22;INVALID_FIT\&#x22;, `Invalid fit \&#x22;${rawFit}\&#x22;. Use one of: ${FIT_MODES.join(\&#x22;, \&#x22;)}`);\n    }\n    if (options.width === undefined && options.height === undefined) {\n      return invalid(\&#x22;INVALID_DIMENSIONS\&#x22;, \&#x22;fit requires width and/or height\&#x22;);\n    }\n    options.fit = rawFit as FitMode;\n  }\n\n  return { ok: true, value: options };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/types/image.ts&#x22;,&#x22;title&#x22;:&#x22;image.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { FIT_MODES, OUTPUT_FORMATS } from \&#x22;../constants/defaults\&#x22;;\n\nexport type OutputFormat = keyof typeof OUTPUT_FORMATS;\nexport type FitMode = (typeof FIT_MODES)[number];\n\nexport type ConvertOptions = {\n  format: OutputFormat;\n  quality?: number;\n  width?: number;\n  height?: number;\n  fit?: FitMode;\n};\n\nexport type ErrorStatus = 400 | 413 | 415 | 502;\n\nexport type Failure = { ok: false; code: string; message: string; status: ErrorStatus };\n\nexport type ParseResult<T> = { ok: true; value: T } | Failure;\n\nexport type ImageBytes = { bytes: Uint8Array; contentType: string };\n\nexport type ConvertedImage = {\n  body: ArrayBuffer;\n  contentType: string;\n  originalSize: number;\n  outputSize: number;\n};\n\nexport type ImageInfo = {\n  format: string;\n  fileSize: number;\n  width: number | null;\n  height: number | null;\n};&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const ALLOWED_SCHEMES = [\&#x22;http:\&#x22;, \&#x22;https:\&#x22;] as const;\n\nexport const OUTPUT_FORMATS = {\n  webp: \&#x22;image/webp\&#x22;,\n  avif: \&#x22;image/avif\&#x22;,\n  jpeg: \&#x22;image/jpeg\&#x22;,\n  png: \&#x22;image/png\&#x22;,\n} as const;\nexport const DEFAULT_FORMAT = \&#x22;webp\&#x22;;\n\nexport const FIT_MODES = [\&#x22;scale-down\&#x22;, \&#x22;contain\&#x22;, \&#x22;cover\&#x22;, \&#x22;crop\&#x22;, \&#x22;pad\&#x22;, \&#x22;squeeze\&#x22;] as const;\n\nexport const MIN_QUALITY = 1;\nexport const MAX_QUALITY = 100;\nexport const MAX_DIMENSION = 4096;\n\nexport const MAX_IMAGE_BYTES = 10 * 1024 * 1024;\nexport const FETCH_TIMEOUT_MS = 15_000;\n\nexport const WATERMARK_OPACITY = 0.6;\nexport const WATERMARK_MARGIN_PX = 16;\n\n/** ImagesError code for \&#x22;input is not a supported image\&#x22;. */\nexport const IMAGES_ERROR_NOT_AN_IMAGE = 9412;&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Typical API: POST /convert?format=webp&quality=80&width=1200 (image body)\n * Requires wrangler.json: \&#x22;images\&#x22;: { \&#x22;binding\&#x22;: \&#x22;IMAGES\&#x22; }\n */\nimport { convertImage } from \&#x22;../src/lib/images\&#x22;;\nimport { readImageBody } from \&#x22;../src/lib/input\&#x22;;\nimport { parseConvertOptions } from \&#x22;../src/lib/params\&#x22;;\n\nexport default {\n  async fetch(request: Request, env: { IMAGES: ImagesBinding }): Promise<Response> {\n    const query = new URL(request.url).searchParams;\n    const options = parseConvertOptions((key) => query.get(key) ?? undefined);\n    if (!options.ok) return Response.json(options, { status: options.status });\n\n    const source = await readImageBody(request);\n    if (!source.ok) return Response.json(source, { status: source.status });\n\n    const image = await convertImage(env.IMAGES, source.value.bytes, options.value);\n    return new Response(image.body, {\n      headers: {\n        \&#x22;Content-Type\&#x22;: image.contentType,\n        \&#x22;X-Original-Size\&#x22;: String(image.originalSize),\n        \&#x22;X-Output-Size\&#x22;: String(image.outputSize),\n      },\n    });\n  },\n};&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/image-converter)
  </Step>

  <Step>
    ### Images binding [#images-binding]

    `wrangler.json` declares `"images": { "binding": "IMAGES" }`. Images must be available on your account. See [Images pricing](https://developers.cloudflare.com/images/pricing/).
  </Step>

  <Step>
    ### Test your deployment [#test-your-deployment]

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

## Local Development [#local-development]

```bash
cd apps/experiments/image-converter
npm install
npm run dev
```

```bash
curl -X POST "http://localhost:8787/info" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
```

## Configuration [#configuration]

`wrangler.json` declares:

* **`images` binding** `IMAGES` (typed as `ImagesBinding`)

## Cloudflare Features Used [#cloudflare-features-used]

* **[Workers](https://developers.cloudflare.com/workers/)** - Edge compute runtime
* **[Images binding](https://developers.cloudflare.com/images/transform-images/bindings/)** - `input()`, `transform()`, `draw()`, `output()`, and `info()` on raw image bytes

## Next Steps [#next-steps]

<Cards>
  <Card title="Image Resizer" href="/experiments/image-resizer">
    URL-based resizing with `fetch` and `cf.image`
  </Card>

  <Card title="Presigned R2 Upload" href="/experiments/presigned-r2-upload">
    Upload images directly to R2 from the browser
  </Card>
</Cards>
