Optimizing Image Loading with AVIF Placeholders for Enhanced Performance
Tags: webdev, performance, beginners
Published: 2024-06-20 13:24:53

It's no secret that page load times have a big impact on user experience, bounce rates, and SEO.

Meanwhile, some of the Pillser pages load a lot of data, e.g.,

* [https://pillser.com/brands/absolute-nutrition](https://pillser.com/brands/absolute-nutrition) (13 products)
* [https://pillser.com/brands/21st-century](https://pillser.com/brands/21st-century) (215 products) (takes a few seconds to load)

(The topic of why I am not using pagination is for another day.)

Therefore, I need to squeeze out every last bit of performance to maximize the user experience.

## LQIP

Low Quality Image Placeholder (LQIP) is a technique that allows us to serve a low quality placeholder image to the browser while the actual image is being loaded.

Example:

![LQIP image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oxq7tm52nfpbtmwnt2h0.png)

The challenge though is that because the image needs to be visible immediately, we need to inline the actual image in the HTML, i.e. every bit counts towards the page size.


Here is what it looks like for the image above:

```html
<div style="border: 1px solid #eee; width: 320px; aspect-ratio: 2469/1606; background-image: url(data:image/png;base64,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);background-size:100% 100%"></div>
```

This LQIP has been generated using [ThumbHash](https://evanw.github.io/thumbhash/). Compared to other implementations of LQIP (like BlurHash or Potato WebP), this one encodes more details in the same space.

However, the above image representation still consumes 2,050 bytes of data, which adds up to ~440 KB for a page with 215 images (like the [21st Century brand page](https://pillser.com/brands/21st-century)). That's a lot!

## AVIF

The realization that I had was that, just how I use [AVIF](https://caniuse.com/?search=AVIF) for product images themselves (because the file size is smaller), I can use AVIF to reduce the size of the LQIP. Here is what the same image looks like in AVIF:

```html
<div style="border: 1px solid #eee; width: 320px; aspect-ratio: 2469/1606; background-image: url(data:image/avif;base64,AAAAHGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZgAAAc1tZXRhAAAAAAAAACFoZGxyAAAAAAAAAABwaWN0AAAAAAAAAAAAAAAAAAAAAA5waXRtAAAAAAABAAAARmlsb2MAAAAAREAAAwACAAAAAAHxAAEAAAAAAAAAFAABAAAAAAIFAAEAAAAAAAAAJgADAAAAAAIrAAEAAAAAAAAAvgAAAE1paW5mAAAAAAADAAAAFWluZmUCAAAAAAEAAGF2MDEAAAAAFWluZmUCAAAAAAIAAGF2MDEAAAAAFWluZmUCAAABAAMAAEV4aWYAAAAA12lwcnAAAACxaXBjbwAAABNjb2xybmNseAACAAIABoAAAAAMYXYxQ4EAHAAAAAAUaXNwZQAAAAAAAAAgAAAAFwAAAA5waXhpAAAAAAEIAAAAOGF1eEMAAAAAdXJuOm1wZWc6bXBlZ0I6Y2ljcDpzeXN0ZW1zOmF1eGlsaWFyeTphbHBoYQAAAAAMYXYxQ4EgAgAAAAAUaXNwZQAAAAAAAAAgAAAAFwAAABBwaXhpAAAAAAMICAgAAAAeaXBtYQAAAAAAAAACAAEEgYYHiAACBIIDhIUAAAAoaXJlZgAAAAAAAAAOYXV4bAACAAEAAQAAAA5jZHNjAAMAAQABAAABAG1kYXQSAAoFGBE/ZhUyCRgAAAEACQwfcxIACgU4ET9mCTIbGAAAAEBJ5HdzPsgdh/pW4sdXS55ZvLbaUbb4AAAABkV4aWYAAElJKgAIAAAABgASAQMAAQAAAAEAAAAaAQUAAQAAAFYAAAAbAQUAAQAAAF4AAAAoAQMAAQAAAAIAAAATAgMAAQAAAAEAAABphwQAAQAAAGYAAAAAAAAASAAAAAEAAABIAAAAAQAAAAYAAJAHAAQAAAAwMjEwAZEHAAQAAAABAgMAAKAHAAQAAAAwMTAwAaADAAEAAAD//wAAAqAEAAEAAAAgAAAAA6AEAAEAAAAXAAAAAAAAAA==);background-size:100% 100%"></div>
```

The above is now 1,019 bytes (or 50% of the original LQIP).

## Using sharp to convert PNG to AVIF

`thumbhash` defaults to producing png images. Perhaps, this is to support a broader range of browsers (AVIF has 93.62% browser support). However, I made a conscious decision that it is an acceptable trade-off to use AVIF for the placeholder images if it means that I can reduce the image size by 50%.

Therefore, I am using `thumbhash` to generate the LQIP, and then using `sharp` to convert the PNG to AVIF. Here is the underlying code:

```ts
import sharp from 'sharp';
import { rgbaToThumbHash, thumbHashToDataURL } from 'thumbhash';

const dataUrlToBuffer = (dataUrl: string) => {
  const match = dataUrl.match(/^data:[^;]+;base64,([^"]+)/u);

  if (!match) {
    throw new Error('Invalid data URL');
  }

  const [, base64] = match;

  return Buffer.from(base64, 'base64');
};


export const generateThumbHashDataUrl = async (image: Buffer) => {
  const smallImage = await sharp(image).resize(100);

  const { data, info } = await smallImage
    .ensureAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true });

  const dataUrl = thumbHashToDataURL(
    rgbaToThumbHash(info.width, info.height, data),
  );

  return `data:image/avif;base64,${(
    await sharp(dataUrlToBuffer(dataUrl)).avif().toBuffer()
  ).toString('base64')}`;
};
```

The conversion happens when uploading the image to the database, therefore the overhead does not impact the user experience.

And that's it! Using this simple technique I was able to significantly reduce the page size when there are a lot of images.