Maximally optimizing image loading for the web

In this post I'll outline 8 image loading optimization techniques to minimize both the bandwidth used for loading images on the web and the CPU usage for image display. I'll present them in the form of an annotated HTML example to make it easy for folks to reproduce the results. Some of these techniques are more established, while others are somewhat novel. Ideally, your favorite mechanism for publishing web documents (like a CMS, static site generator, or web application framework) implements all of these out-of-the-box. I'll keep a list updated at the end of this posts with technologies that provide all of the optimizations outlined here.

Together the techniques optimize all elements of Google's Core Web Vitals by

View the source of this sample image to see all the techniques in action:

Sample image illustrating the techniques outlined in this post.

Optimization techniques #

Responsive layout #

This is a well understood technique to make an image use the available horizontal space up until its maximum size while retaining the aspect ratio. New in 2020 is that web browsers will reserve the correct vertical space for the image before it loads if the width and height attributes are provided for the img element. This avoids Cumulative Layout Shift (CLS).

<style>
img {
max-width: 100%;
height: auto;
}
</style>
<!-- Providing width and height is more important than ever. -->
<img height="853" width="1280" />

Lazy rendering #

The second technique is more cutting edge. The new CSS attribute content-visibility: auto instructs the browser to not bother layouting the image until it gets near the screen. This has all kinds of benefits, but the most important one might be that the browser will not bother decoding our blurry placeholder image or the image itself unless it has to, saving CPU.

Update 01/27/2021 contain-intrinsic-size no longer needed #

An earlier version of this post explained how to use contain-intrinsic-size to avoid CLS impact of content-visibility: auto but as of Chromium 88 this is no longer needed for images that provide width and height as explained above. Other browser engines do not yet (01/27/2021) implement content-visibility: auto and would presumably follow Chromium's lead on this special case. So, yaihh, this is much simpler now!

<style>
/* This probably only makes sense for images within the main scrollable area of your page. */
main img {
/* Only render when in viewport */
content-visibility: auto;
}
</style>

AVIF #

AVIF is the most recent image format that has gained adoption in web browsers. It is currently supported in Chromium browsers, and available behind a flag in Firefox. Safari support isn't available yet, but given that Apple is a member of the group that is behind the format, we can expect future support.

AVIF is notable because it very consistently outperforms JPEG in a very significant way. This is different from WebP which doesn't always produce smaller images than JPEG and may actually be a net-loss due to lack of support for progressive loading.

For more info on AVIF encoding and quality settings, check out my dedicated blog post.

To implement progressive enhancement for AVIF, use the picture element.

The actual img element is nested in the picture. This can be quite confusing, because the img is sometimes described as fallback for browsers without picture support but basically the picture element only helps with src selection but has no layout itself. The element that is drawn (and which you style) is the img element.

Until very recently it was relatively difficult to actually encode AVIF images on the server-side, but with the latest version of libraries like sharp it is now trivial.

<picture>
<source
sizes="(max-width: 608px) 100vw, 608px"
srcset="
/img/Z1s3TKV-1920w.avif 1920w,
/img/Z1s3TKV-1280w.avif 1280w,
/img/Z1s3TKV-640w.avif 640w,
/img/Z1s3TKV-320w.avif 320w
"

type="image/avif"
/>

<!-- snip lots of other stuff -->
<img />
</picture>

Load the right number of pixels #

You might have noticed the srcset and sizes attributes in the snippet above. Using the w selector it tells the browser which URL to use based on the physical pixels that would be used if the image was drawn to the user's device given the width calculated from the sizes attribute (which is a media query expression).

With this the browser will always download the smallest possible image that provides the best image quality for the user. Or it may select a smaller image if, for example, the user has opted into some kind of data-saving mode.

Fallbacks #

Provide more source elements with srcsets for browsers that only support legacy image formats.

<source
sizes="(max-width: 608px) 100vw, 608px"
srcset="
/img/Z1s3TKV-1920w.webp 1920w,
/img/Z1s3TKV-1280w.webp 1280w,
/img/Z1s3TKV-640w.webp 640w,
/img/Z1s3TKV-320w.webp 320w
"

type="image/webp"
/>

<source
sizes="(max-width: 608px) 100vw, 608px"
srcset="
/img/Z1s3TKV-1920w.jpg 1920w,
/img/Z1s3TKV-1280w.jpg 1280w,
/img/Z1s3TKV-640w.jpg 640w,
/img/Z1s3TKV-320w.jpg 320w
"

type="image/jpeg"
/>

Caching / Immutable URLs #

Embed a hash of the bytes in the image in the URL of the image. In the examples above I'm doing that with the Z1s3TKV in the image URLs. That way the URL will change if the image changes and respectively you can apply infinite cache expiration for your images. You want your caching headers to look something like this cache-control: public,max-age=31536000,immutable.

immutable is the semantically correct cache-control value, but unfortunately it isn't widely supported in browsers (I'm looking at you, Chrome). max-age=31536000 is the fallback to cache for a year. public is important to allow your CDN to cache the image and deliver it from the edge. But only use that if it is appropriate from a privacy perspective.

Lazy loading #

Adding loading="lazy" to the img instructs the browser to only start fetching the image as it gets closer to the screen and is likely to actually be rendered.

<img loading="lazy"  />

Asynchronous decoding #

Adding decoding="async" to the img gives the browser permission to decode the image off the main thread avoiding user impact of the CPU-time used to decode the image. This should have no discernible downside except that it cannot always be the default for legacy reasons.

<img decoding="async"  />

Blurry placeholder #

A blurry placeholder is an inline image that provides the user some notion of the image that will load eventually without requiring fetching bytes from the network.

Sample blurry placeholder

Some notes on the implementation provided here:

  • It inlines the blurry placeholder as a background-image of the image. This avoids using a second HTML element and it naturally hides the placeholder when the image loads, so that no JavaScript is needed to implement this.
  • It wraps the data URI of the actual image in a data URI of a SVG image. That is done because the blurring of the image is done at the SVG level instead of through a CSS filter. The result is that the blurring is only performed once per image when the SVG is rasterized, instead of on every layout saving CPU.
<img
style="

background-size: cover;
background-image:
url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns=\'http%3A//www.w3.org/2000/svg\'
xmlns%3Axlink=\'http%3A//www.w3.org/1999/xlink\' viewBox=\'0 0 1280 853\'%3E%3Cfilter id=\'b\' color-interpolation-filters=\'sRGB\'%3E%3CfeGaussianBlur stdDeviation=\'.5\'%3E%3C/feGaussianBlur%3E%3CfeComponentTransfer%3E%3CfeFuncA type=\'discrete\' tableValues=\'1 1\'%3E%3C/feFuncA%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Cimage filter=\'url(%23b)\' x=\'0\' y=\'0\' height=\'100%25\' width=\'100%25\'
xlink%3Ahref=\'data%3Aimage/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAGCAIAAACepSOSAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAs0lEQVQI1wGoAFf/AImSoJSer5yjs52ktp2luJuluKOpuJefsoCNowB+kKaOm66grL+krsCnsMGrt8m1u8mzt8OVoLIAhJqzjZ2tnLLLnLHJp7fNmpyjqbPCqLrRjqO7AIeUn5ultaWtt56msaSnroZyY4mBgLq7wY6TmwCRfk2Pf1uzm2WulV+xmV6rmGyQfFm3nWSBcEIAfm46jX1FkH5Djn5AmodGo49MopBLlIRBfG8yj/dfjF5frTUAAAAASUVORK5CYII=\'%3E%3C/image%3E%3C/svg%3E');
"


/>

(Optional-ish) JavaScript optimization #

Browsers may feel obliged to rasterize the blurry placeholder even if the image is already loaded. By removing it on image load, we solve that problem. Also, if your images contain transparency, then this is actually not optional as otherwise the placeholder would shine through.

<script>
document.body.addEventListener(
"load",
(e) => {
if (e.target.tagName != "IMG") {
return;
}
// Remove the blurry placeholder.
e.target.style.backgroundImage = "none";
},
/* capture */ true
);
</script>

Tools #

This is a list of known technologies and tools implementing all of these optimizations:

If you know of a technology (can be a combination of multiple "modules" or similar if they work well together) that should be on this list, please ping me.

Published