Field notes

I was sleeping on Cloudflare Images

Every app that lets people upload pictures ends up growing the same organ: an image pipeline. A user drops in a 4MB photo from their phone, and now you have to accept it, validate it, maybe strip EXIF, resize it into a thumbnail and a card and a hero, re-encode each one into WebP and AVIF, push all of that to object storage, and wire a CDN in front so the bytes don’t travel from your origin every time. Then a designer changes the thumbnail from 100px to 120px and you get to reprocess the entire back catalog.

I built some version of that pipeline more than once before I stopped and actually looked at Cloudflare Images. The uncomfortable realization: most of that machinery is incidental complexity. Cloudflare Images collapses “store the original, generate every size, optimize the format, and serve it fast” into a URL. This post is the walkthrough I wish I’d read before writing all that glue code.

The DIY pipeline you probably built

The standard homegrown setup has four moving parts, and each one is a place for things to go wrong:

  1. Storage. An S3 or R2 bucket holding the original and every derived size. Your storage footprint quietly multiplies by however many variants you decided to pre-bake.
  2. Processing. A Lambda or a background worker running sharp/ImageMagick to resize and re-encode. It has to be triggered on every upload, retried on failure, and kept warm enough to not add seconds to the request.
  3. Format negotiation. Logic somewhere that decides whether this browser gets AVIF, WebP, or a JPEG fallback, usually by sniffing the Accept header.
  4. A CDN. Cache configuration in front of all of it so you’re not paying origin egress on every view.

The tax isn’t just the initial build. It’s that the set of sizes is baked in at upload time. The day you want a new crop, you’re writing a migration to reprocess everything you’ve ever stored.

What Cloudflare Images actually is

It helps to see it as two features that happen to share a name, because you can use them independently.

Transformations optimize images that live anywhere: an R2 bucket, your own origin, any public URL. You don’t move your files. You just wrap the image URL with resize/format parameters and Cloudflare does the work on the fly at the edge, then caches the result. This tier is on the Free plan, with up to 5,000 unique transformations a month at no cost.

Storage is the hosted product. You upload originals into Cloudflare Images, and it gives you a delivery URL, named variants, and the same on-the-fly transformation engine. This one needs a Paid plan.

The mental model that unlocks it: store the original once, describe every derivative in the URL. No pre-baking, no reprocessing migrations. If you need a new size next year, you type a different number into the URL and it exists.

Transformations: the part that’s free

If your images already live in R2 or on your origin, you can get most of the value without adopting the storage product at all. A transformation is expressed as a path segment of options in front of the source image:

https://example.com/cdn-cgi/image/width=400,quality=75,format=auto/https://assets.example.com/photos/hero.jpg

That single URL says: fetch the source, resize to 400px wide, encode at quality 75, and pick the best modern format the requesting browser supports. The result is cached at the edge, so the expensive part happens once per unique transformation, not once per view.

The parameter list is deep, but a handful cover almost everything:

ParameterWhat it does
width / heightTarget dimensions in pixels (w / h for short)
fitHow the image fills the box: scale-down, contain, cover, crop, pad, and more
gravityWhich part to keep when cropping: a side, a {x,y} point, auto (saliency detection), or face
quality1 to 100, or perceptual levels like high / medium-low. Default is 85
formatauto picks AVIF/WebP per browser; you can also force one
dprDevice pixel ratio, up to 2, for crisp Retina output
blur0 to 250, handy for placeholders and previews

format=auto is the quiet hero here. It’s the entire “sniff the Accept header and serve AVIF to Chrome, WebP to slightly older browsers, JPEG to the stragglers” chore, deleted and replaced with one word.

One security note worth internalizing early: blur and friends applied via the URL are cosmetic, not protective. Anyone can edit the URL and strip the blur off. If you’re obscuring something sensitive, do the transform in a Worker where the original URL never reaches the client.

The features that used to mean a paid API

Resizing and format conversion are table stakes. The part that genuinely surprised me is that a couple of things I’d previously reached for a dedicated (and expensive) third-party service to do are also just URL parameters.

Background removal. Add segment=foreground and Cloudflare isolates the subject and makes the background transparent. Under the hood it’s the open-source BiRefNet model running through Workers AI. For a product catalog, cutting a clean product shot out of a messy photo is normally a per-image bill to some SaaS. Here it’s a query param (output to WebP or PNG, since JPEG has no alpha channel):

https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/w=800,segment=foreground,format=webp

Face-aware smart cropping. Cropping a wide hero out of a portrait without decapitating anyone is the kind of thing you either do by hand or pay for. gravity=face finds the face and crops around it; pair it with zoom (0.0 to 1.0) to control how tight:

https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/w=1600,h=500,fit=cover,gravity=face,zoom=0.2

And when you don’t know what’s in the image, which is the usual case for user uploads, gravity=auto runs a saliency algorithm to keep the most visually interesting region. That one line replaces “manually set a focal point for every image in the catalog.”

It also quietly handles the annoying input formats. Upload a HEIC straight off an iPhone and Images ingests it, serving back WebP/AVIF/JPEG/PNG as needed. No client-side conversion dance.

Variants: name your sizes once

Once you’re storing images in Cloudflare, hardcoding width=100,height=100,fit=cover across your codebase gets old. Variants let you define a named preset once and reference it by name:

https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/thumbnail
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/hero

You define thumbnail as 100×100 and hero as 1600×500 in the dashboard, and the URLs stay clean and semantic. Change the definition of thumbnail and every thumbnail URL reflects it immediately, with no reprocessing, because nothing was ever pre-baked. Defining variants doesn’t count against your storage, either; only uploaded originals do.

If you’d rather keep the flexibility of arbitrary parameters but still gate what’s allowed, there are flexible variants, which let URL-level options work on your hosted images without predefining every combination.

Doing it in a Worker with the Images binding

The URL interface handles the common case. When you need logic (validating an upload, watermarking, restricting access, chaining operations), there’s an env.IMAGES binding you can call from a Worker.

Resize and transcode a hosted image:

// Get the raw bytes of a hosted image
const bytes = await env.IMAGES.hosted.image("IMAGE_ID").bytes();

// Resize and re-encode to WebP
const response = (
  await env.IMAGES.input(bytes)
    .transform({ width: 400 })
    .output({ format: "image/webp" })
).response();

return response;

Transforms chain, and the order you call them in is the order they apply:

const response = (
  await env.IMAGES.input(stream)
    .transform({ rotate: 90 })
    .transform({ width: 128 })
    .transform({ blur: 20 })
    .output({ format: "image/avif" })
).response();

And because a common real-world need is stamping user uploads, there’s a .draw() method for overlays and watermarks. You draw one input() chain on top of another before calling .output():

const imageResponse = (
  await env.IMAGES.input(fileStream)
    .draw(
      env.IMAGES.input(watermarkStream).transform({ width: 100, height: 100 }),
      { bottom: 10, right: 10, opacity: 0.75 },
    )
    .output({ format: "image/avif" })
).response();

A nice detail for development: using the Images binding locally through Wrangler doesn’t incur usage charges, so you can iterate on transform logic without watching a meter.

Accepting uploads from users

For a marketplace or social app, you also need the inbound path. Cloudflare covers a few shapes:

  • Upload via the API. POST the file bytes to the Images endpoint from your backend.
  • Upload via URL. Hand Cloudflare a source URL and it fetches the image itself, no proxying through your server.
  • Direct creator uploads. Mint a one-time upload URL so the browser sends the file straight to Cloudflare, keeping large uploads off your infrastructure entirely. You could build the equivalent against a raw storage bucket, but then you’re the one managing token expirations and signing requests by hand; here that’s handled for you.
  • Upload from a Worker with env.IMAGES.hosted.upload(...), or bulk-import from S3 with Sourcing Kit.

Every successful upload returns an image ID and the set of variant URLs, ready to store next to your record.

The pricing that made me feel silly

This is the part that reframed it for me. The pricing is unbundled into three metrics you can reason about:

MetricCost
Images TransformedFirst 5,000 unique transformations/month free, then $0.50 per 1,000
Images Stored$5 per 100,000 images stored/month
Images Delivered$1 per 100,000 images delivered/month

Cloudflare’s own worked example: a product page serving 10 images, visited 10,000 times in a month, is 100,000 deliveries. One dollar. A catalog of 100,000 stored images with a few million monthly deliveries lands in the tens of dollars a month, all-in.

Set that against the DIY alternative and it stops being close. The homegrown pipeline’s real cost was never the object storage. It was the processing service to maintain, the format-negotiation edge cases, the reprocessing migrations, and the engineering hours feeding all of it. Paying single-digit dollars to delete that whole category of work is not a hard trade.

(If you keep your originals in R2 and only use the transformation tier, you’re billed for Images Transformed and normal R2 storage, and skip the Delivered/Stored line items entirely.)

When the DIY route still wins

Cloudflare Images isn’t the universal answer, and it’s worth naming where it isn’t:

  • Truly specialized processing. Generative fill, complex multi-layer compositing, or bespoke ML models still live outside the box. (Background removal and smart cropping, notably, do not; those are built in, as above.) A common pattern is to process on a specialized service and store the result in Images just for delivery.
  • Extreme scale with a tuned pipeline. At very high volume, R2 + Workers with your own transform logic can be cheaper than managed Images. You trade simplicity back for control.
  • You’ve already built and battle-tested a pipeline. If the machinery exists and works, there’s no prize for rewriting it. The argument here is for the next app, before you write the glue.

The takeaway

The thing I’d internalized wrong was treating image handling as an application concern I had to solve: storage schema, processing jobs, format matrices, cache rules. Cloudflare Images reframes it as infrastructure you configure. Store the original once, and describe every size, crop, and format as a parameter that’s resolved and cached at the edge on demand.

If you’re reaching for sharp and a queue and a bucket-of-thumbnails schema for a new project, pause first. There’s a good chance the whole thing is a URL.

Sources