Progressively delivering new image formats with CSS and Cloudflare Workers

Update: As of June 2021, Firefox 89 is the first browser to support the type() function within image-set(), enabling us to do this with purely CSS.

Update 2: As of June 2023, Chrome 113, and Safari 17 also support the type() function within image-set(), making this usable in all major browsers!


Progressive enhancement is a core strategy on the web that allows you to build on the foundation of your website and design, by adding additional features/components progressively where possible.

Accompanying this is web performance, and the ideal of keeping your page weight as small as possible, in order to improve user experience. Google also recently announced that page experience signals will be a ranking factor in Google Search starting May 2021, so there's no better time to start improving your page's Core Web Vitals!

Heavy Images

Images are arguably the biggest contributor to page weight today. The Web Almanac for 2019 reported that images comprise almost 75% of the total page weight of a page, with unoptimized images being the worst offender.

Traditionally, images are delivered in a few very popular formats such as PNG, JPEG, and GIF. Modern browsers support some newer formats such as WebP, AVIF, and JPEG XL, which offer higher efficiency and better compression, with no visible quality degradation. Jake Archibald has a great article talking about all of the great benefits of AVIF that you can check out on their blog if you'd like to read more.

Delivering New Image Formats

A great way to reduce page weight is to use the best image formats for your specific needs, as well as delivering newer image formats with better efficiency and/or higher compression to browsers that support them.

HTML

In HTML, we can progressively deliver new image formats to browsers that support them through the use of the picture element. With something like the following, you can serve a JXL or AVIF image to extremely new browsers, WebP to others, and then finally fallback to a PNG for older browsers.

<picture>
    <source type="image/jxl" srcset="zebra.jxl">
    <source type="image/avif" srcset="zebra.avif">
    <source type="image/webp" srcset="zebra.webp">
    <img alt="Zebra" src="zebra.jpg">
</picture>

This is really awesome, because it lets you reduce the size of your images on newer browsers, whilst still maintaining the exact same functionality on older browsers, albeit with larger image sizes.

What about CSS?

The picture element is great, but sometimes we set large background-images with CSS for purely decorative purposes on a page.

The image-set function in CSS seems to be a step in the right direction, but browser support thus far is very fractured, and differs a lot between browsers. An updated spec for CSS Images Module Level 4 and the image-set function does show promise with support for this use-case though with something like the following:

background-image:
    image-set(
		url("zebra.jxl") type("image/jxl"),
        url("zebra.avif") type("image/avif"),
        url("zebra.webp") type("image/webp"),
        url("zebra.png") type("image/png")
    );

This unfortunately does not work today in any browser though, and as far as I have discovered, there is no purely CSS way to accomplish the same progressive delivery that the picture element can do today in HTML.

Workarounds for CSS

There are a few workarounds to mimic similar behaviour in CSS, but they all come with caveats.

  • Content Negotiation

 With content negotiation server-side, you could dynamically serve an AVIF image to supported browsers, and the original PNG to unsupported browsers. This is okay when you have full control of the server, but when you don't, you'd have to proxy the image or something, and that quickly gets messy, and adds a layer of complexity to your server.

  • Detect Support Using JavaScript

 There are many ways to detect client-side if the browser supports WebP or AVIF. Popular libraries such imgsupport add various classes to your html document such as avif which then allow you to target them with CSS.

This is okay, but excludes non-JS users, and requires you to have render-blocking JS in your head, unless you want images to be downloaded twice as the script is executed with defer, which would defeat the entire purpose of optimizing your page weight.

  • Cloudflare Workers (or something similar)

 Using a platform on the edge such as Cloudflare Workers, you could dynamically alter the returned HTML document, inferring information from the accept header, and then add the webp/avif etc. classes as appropriate.

If you're already using something like Cloudflare Workers to enhance your pages, this is an extremely easy thing to add. After the initial Worker configuration, this allows you to serve new image formats to your users with just a few extra lines of CSS! If you're not using something like Cloudflare Workers, then this method adds some technical complexity to your site, but as I'm very familiar with Workers, I'll be using it for the rest of this tutorial, since it's the best available option today in my opinion.

Writing your Worker

I've talked about Cloudflare Workers a lot on my blog before, with much more in-depth tutorials and beginner guides, so if you're looking for an introduction, I'd recommend checking out some of my other posts.

For our purposes here though, we're going to be using Cloudflare Workers HTMLRewriter to accomplish the following:

  • Check the user's Accept header
  • Using HTMLRewriter, append a webp and/or avif class to the body element if the respective image format is accepted

The following example Worker is something I threw together quickly, and works great.

addEventListener('fetch', event => {
	event.respondWith(handleRequest(event.request));
});

class bodyHandler {
	constructor(headers) {
		const acceptHeader = headers.get('accept');
		if(acceptHeader && acceptHeader.includes('image/webp')){
			this.webp = true;
		}
		if(acceptHeader && acceptHeader.includes('image/avif')){
			this.avif = true;
		}
		if(acceptHeader && acceptHeader.includes('image/jxl')){
			this.jxl = true;
		}
	}
	element(element) {
		let currentClass = element.getAttribute('class');
		if(this.webp){
			currentClass += ' webp';
		}
		if(this.avif){
			currentClass += ' avif';
		}
		if(this.jxl){
			currentClass += ' jxl';
		}
		element.setAttribute('class', currentClass.trim());
	}
}

async function handleRequest(request) {
	const fetcher = await fetch(request);
	const rewriter = new HTMLRewriter();
	rewriter.on('body', new bodyHandler(request.headers));
	return rewriter.transform(fetcher);
}

Now, your body element will have a jxl,  avif and/or webp class as soon as it's delivered to the user, allowing you to target these in CSS without any delays or double-downloads that could occur with a client-side JS based solution.

Using The Cloudflare Worker

As an example, let's we have something like this in our HTML/CSS:

<div class="pattern"></div>
.pattern {
    background-image: url(/images/pattern.png)
}

With the addition of our Cloudflare Worker, we can add a few extra lines of CSS, and deliver the newer JXL/AVIF/WebP versions of our pattern.png to the client, once again reducing page-weight!

.pattern {
    background-image: url(/images/pattern.png)
}

/* Serve AVIF image to supported browsers */
/* Include browsers that support JXL, AVIF and WebP for CSS specificity */
body.jxl .pattern,
body.jxl.avif .pattern,
body.jxl.avif.webp .pattern {
	background-image: url(/images/pattern.avif)
}

/* Serve AVIF image to supported browsers */
/* Include browsers that support both WebP and AVIF for CSS specificity */
body.avif .pattern,
body.webp.avif .pattern {
	background-image: url(/images/pattern.avif)
}

/* Serve WebP image to supported browsers */
body.webp .pattern {
	background-image: url(/images/pattern.webp)
}

Real World Results

I deployed a similar solution to my company's site Nodecraft.com a couple of weeks ago, and we saw our average page weight decrease from around 2.6MB, to under 1MB. This is a huge improvement for an image-heavy homepage!

Graph showing Total Page Size for nodecraft.com reducing from 2.6MB to less than 1MB

This also resulted in improvements to many Core Web Vitals as can be seen in the graph below. (The discrepancy in CLS is a result of a bug in Chrome with content-visibility that has been resolved).

Graph showing Core Web Vitals metrics for Nodecraft.com
You've successfully subscribed to James Ross
Great! Next, complete checkout for full access to James Ross
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.