Skip to main
a head full of oat milk cappuccinos by
Timo Mämecke
Jump to navigation
· 4 minute read

Vary for images on Cloudflare CDN for free

Let’s rebuild a paid Cloudflare feature … on Cloudflare, for free.

Vary for images is a Cloudflare CDN feature that caches multiple variants of the same URL, based on the browser’s specific capabilities. It’s a paid feature. I want it, but I didn’t want to pay for it. That’s no issue, because we can rebuild this functionality on Cloudflare completely for free, with just a bit more configuration.

What’s the problem that I’m trying to solve?

I have a small image optimization service running. It can generate different image sizes and compresses the image on the fly, to improve loading times for users. It can also turn the image into a different format, like WebP or AVIF, if the browser supports it.

This means that the URL timomeh.de/keyboardcat.png can return an image that’s not really a png. When the browser supports AVIF, it would return it as an AVIF, even though the URL ends with .png. When it supports WebP, it returns a WebP, still using the .png extension. When it supports neither, it actually returns a PNG. We have the same URL, but different files can be returned.

The image optimization service is doing that by looking at the Accept header, to see which image formats the browser supports, and then it decides which format to return. We also call this content negotiation.

But this image optimization service is doing all of this on the fly. It doesn’t cache images that it has already generated. Optimizing each image on every request is a bit slow and wasteful. We could add caching into our image optimization service, but that’s more work. We can also just use a CDN, like Cloudflare, and let it do the caching for us.

But if we just enable Cloudflare’s free CDN for our image optimization service, we’ll run into an issue

Cloudflare’s CDN caches files by its URL. When someone requests timomeh.de/keyboardcat.png, it fetches the image from my service and caches it. The next time someone tries to load that file, Cloudflare will see that it already has a file cached for that URL, and will return it directly to the user.

So when the first user’s browser supports AVIF, it will cache the optimized AVIF file. And when the second browser doesn’t support AVIF, they will also get the optimized AVIF file, and they won’t be able to see the image. That’s not great.

To solve that, Cloudflare offers Vary for images, which also does content negotiation, and only returns cached variants that the browser supports. This is a paid feature.

How we can still use content negotiation on Cloudflare’s CDN for free

Cloudflare also has free URL Rewrites, which are actually pretty powerful! We can even rewrite a URL based on an HTTP header, like the Accept header. You might see where this is going: we can use this to rewrite the URL into multiple different URLs for the different image formats, based on what format the browser supports.

Let’s create the first URL Rewrite rule. Go into Cloudflare’s Dashboard, navigate to Rules and create a new rule. Choose URL Rewrite Rule. We’ll use a custom filter expression:

(http.host eq "timomeh.de"
 and any(lower(http.request.headers["accept"][*])[*]
   contains "image/avif"
))

Then, under “Rewrite to”, we’ll use a static query param, like ?format=avif.

Now, when a user requests timomeh.de/keyboardcat.png and the browser supports AVIF, then the URL is rewritten to timomeh.de/keyboardcat.png?format=avif. And this new URL is used to cache the file on the CDN.

My image optimization service is doing absolutely nothing with this query parameter. It doesn’t look at it, it still just looks at the Accept header. The only reason for this query parameter is to have a unique URL for Cloudflare’s CDN.

We can create another rewrite rule for only WebP support:

(http.host eq "timomeh.de"
 and any(lower(http.request.headers["accept"][*])[*]
   contains "image/webp")
 and not any(lower(http.request.headers["accept"][*])[*]
   contains "image/avif")
)

…and we’ll use ?format=webp as rewrite query.

In total, this will automatically get us 3 different URLs:

  • timomeh.de/keyboardcat.png?format=avif
  • timomeh.de/keyboardcat.png?format=webp
  • timomeh.de/keyboardcat.png

They will be separately cached. But we don’t have to link to those 3 different URLs ourselves. We can just continue to link to timomeh.de/keyboardcat.png. Cloudflare does the rest for us.

Done

And now we have content negotiation for free!

To make sure, we can quickly check that content negotiation is working properly by making 3 different curl requests:

$ curl -I \
    -H "Accept: image/avif,image/webp,image/*;q=0.8,*/*;q=0.5" \
    "https://timomeh.de/keyboardcat.png"
HTTP/2 200
content-type: image/avif
content-length: 36665
content-disposition: inline; filename="keyboardcat.avif"
server: cloudflare
cf-cache-status: HIT

$ curl -I \
    -H "Accept: image/webp,image/*;q=0.8,*/*;q=0.5" \
    "https://timomeh.de/keyboardcat.png"
HTTP/2 200
content-type: image/webp
content-length: 35696
content-disposition: inline; filename="keyboardcat.webp"
server: cloudflare
cf-cache-status: HIT

$ curl -I \
    -H "Accept: image/jpeg,image/png,image/*;q=0.8,*/*;q=0.5" \
    "https://timomeh.de/keyboardcat.png"
HTTP/2 200
content-type: image/png
content-length: 59883
content-disposition: inline; filename="keyboardcat.png"
server: cloudflare
cf-cache-status: HIT

We can see it’s working: the first one returns an AVIF, the second one a WebP, and the third returns a PNG. All served by Cloudflare, and we got nice cache hits.