Using Cloudflare Images for Image Storage and Transformation
# Dev NotePutting an image on a web page is probably the easiest thing you’ll do in frontend:
<img src="YOUR_IMAGE_URL" />
One line, done. So easy that it’s easy to forget how much detail hides behind that src.
Images are the heaviest thing on a web page. Text is a few KB; an unprocessed photo is easily several MB. Whether a site feels fast often has nothing to do with your JavaScript — it’s the few seconds spent loading images.
Saving bytes starts with compression. JPEG uses the discrete cosine transform to throw away the high-frequency detail the eye barely notices, shrinking the file dramatically — it’s lossy, but you can hardly tell by looking.
WebP and AVIF have since pushed compression a good deal further, at the cost of some older browsers not supporting them, which is why you reach for <picture> and a fallback:
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img src="hero.jpg" />
</picture>
The browser picks the first format it supports, top to bottom. The width/height, srcset, sizes, and art-direction side of things I covered more fully in another post, so I won’t repeat it here.
This post is about the other half: where do all these different formats and sizes actually come from?
For a better experience on the web, you typically end up doing this to an image:
- Shrink it into a list thumbnail, a mid-size for the inner page, and a large version
- Generate an AVIF, a WebP, and a JPEG at each size
- Decide which one to actually send based on the device’s screen width and DPR, or pick on the frontend at runtime
- Put those files in storage, with a CDN cache in front
If it’s a fixed set of static files, you can compile every format you need ahead of time at build. But on a UGC platform you don’t control the count — users upload, and the work happens on the server.
None of these steps is hard on its own. What’s hard is doing it completely and having it hold up under traffic; put those two together and the cost climbs out of proportion. The core issue: image transcoding is CPU-bound work.
Take AVIF. It’s derived from the keyframes of AV1 video1, and video coding is by nature “expensive to encode, cheap to decode” — it deliberately front-loads the CPU cost onto the encoder so playback decodes smoothly.
AVIF files are small, browsers decode them fast, and the quality is much better for the size — but encoding an image to AVIF is very CPU-heavy. Jake Archibald measured that at libavif’s highest effort setting, a single image can take over ten minutes to encode1. You’d never turn it up that high, but the number tells you the order of magnitude — AVIF encoding is several times more expensive than JPEG, by design.
That’s what makes “transcode on the fly on my own server,” the most intuitive approach, dangerous. Get enough images, hit a traffic spike, and that transcoding saturates the CPU, crowds out your other business logic, and makes OOM all the more likely.
Next.js’s next/image, for one, optimizes on demand using server resources by default, with Sharp underneath. It really is convenient — but it drops a CPU-hungry, publicly exposed endpoint right into your app.
The docs themselves tell you to set up qualities and remotePatterns allowlists, or it becomes a hole someone uses to transcode at scale and blow out your memory2. I personally don’t like the framework reaching into this layer; it’s one more piece of complexity I don’t fully control.
For any high-traffic system, be very careful with any task that is CPU-bound and eats a lot of memory. Once traffic grows, the server hits a wall easily, and it can drag down the rest of your business logic with it.
So offload the image work elsewhere, via Lambda or some async pipeline? You can, but the complexity you then have to handle rises exponentially.
AWS packages its own answer for this (formerly Serverless Image Handler, now renamed Dynamic Image Transformation for Amazon CloudFront)3, and the parts list alone tells you how deep the water is: CloudFront as the cache layer, API Gateway as the entry point, Lambda running Sharp for the transcoding, S3 for the originals and logs, Amazon Rekognition if you want smart cropping, Secrets Manager for signed URLs if you want to stop hotlinking. Every one of those is something to configure, maintain, and pay for.
There’s also a cost that’s easy to overlook: bandwidth. Images move a lot of it, and cloud egress is billed separately.
Serving straight from S3 to the internet is especially pricey, so the standard move is to put CloudFront in front and let the cache absorb requests before they reach the origin. But here’s the trap: the more derivatives you cut (sizes × formats), the more your cache fragments, and every miss means going back to read from S3, transcoding again, and sending again — you pay for the same traffic twice. The multi-format, multi-size setup you built to save bandwidth ends up diluting your cache’s effectiveness.
So the difficulty isn’t in any single step. It’s that this is a line that needs long-term maintenance and scales its cost with your traffic. Unless your company is already big enough to absorb the development cost and there’s real strategic value in owning it, the best strategy for something like this is not to build it yourself.
Cloudflare Images already did it for you
Whenever there’s an image-processing requirement, I now just use Cloudflare Images. Everything above, it handles for you. Similar services include BunnyCDN and ImageKit.
You upload the original once. For different sizes and formats, you don’t pre-cut dozens of files — you pass parameters in the URL and Cloudflare transforms it at the edge on the fly.
With flexible variants turned on, a URL looks like this4:
https://imagedelivery.net/<account_hash>/<image_id>/w=400,quality=80
Format negotiation is automatic. On its delivery URL, Cloudflare reads the browser’s Accept header — AVIF if it’s supported, WebP if that’s all there is, falling back to the original format otherwise5 — so you don’t have to write out a <picture> or decide anything yourself.
For example, copy this image’s URL (https://image.kalan.dev/b856ee69-6431-48ed-7f22-3311e7d01600/normal) into your browser and watch the Network tab: the returned format is AVIF or WebP depending on what your browser supports — even though there’s only ever been one original and I did no transcoding at all.
The common operations are built in too: resize, crop (including face-based cropping), blur, flip, rotate, brightness and contrast.
You can point your own domain at it, of course. All my images live on image.kalan.dev; the only requirement is that the domain be a zone under the same Cloudflare account6.
CDN caching is the default. Transformed images go straight into Cloudflare’s edge cache; after the first request, the same (original + parameters) pair comes back from the nearest node and never touches the origin again.
It actually comes in two flavors: store the images on Cloudflare directly (Hosted Images), or keep them in your own storage (R2 or S3, either works) and just borrow its edge transcoding (now called Transformations).
Billing follows those two. Hosting there adds storage and delivery volume; borrowing only the transcoding bills only per transformation, and there’s a free tier7. The current pricing is:
| Metric | Pricing |
|---|---|
| Images Transformed | First 5,000 unique transformations included + $0.50 / 1,000 unique transformations / month |
| Images Stored | $5 / 100,000 images stored / month |
| Images Delivered | $1 / 100,000 images delivered / month |
I also wrote a small CLI to make uploading images to Cloudflare Images from local convenient — take a look if you need it.
Footnotes
-
AVIF has landed — Jake Archibald. AVIF is derived from AV1 keyframes; the article measures a single image taking over ten minutes at libavif’s highest effort. ↩ ↩2
-
next/image docs, on on-demand optimization, memory limits, and the
qualities/remotePatternsallowlists. ↩ -
Dynamic Image Transformation for Amazon CloudFront (formerly Serverless Image Handler). ↩
-
Cloudflare Images pricing. Check the official page for the current free tier and rates. ↩
Related Posts
- Stop Using Access Keys AlreadyAccess Keys are an easily overlooked security risk on AWS. Use OIDC with IAM Roles so GitHub Actions can securely access AWS resources without any secrets.
- Database Primary Keys: AUTO_INCREMENT, UUID, and UUIDv7Backend developers often have to decide on a primary key: auto increment or UUID? What about collisions? How much faster is UUIDv7 compared with created_at + index? After benchmarking 20 million rows and looking at the design trade-offs, this post gives you the answer.
- Sharing My Experience with ZeaburIndependent developers often choose platforms like Vercel for deploying their services. However, when more advanced requirements arise, such as database connections, Vercel can become less convenient. Additionally, the pricing of typical cloud service providers can be quite expensive for solo developers. In this article, I’ll share some insights on using Zeabur and highly recommend it to everyone!
- Keyboard Enthusiast's Guide - Firmware EditionThis article is part of the IT 2023 Ironman Competition: A Beginner's Guide to Keyboards - Firmware Edition.