Skip to main content

Optimizing Image Loading with AVIF Placeholders for Enhanced Performance

Written by on .

#web-development, #performance, #avif, #lqip

Update (2024-06-21 09:43:05):

I've learned that I should not be converting images to avif on the server. Instead, I should be generating only the thumbhash, passing that to the client, and then generating the actual image.

Here is the code to do that:

const base64ToUint8Array = (base64: string): Uint8Array => {
  const binaryString = atob(base64);
  const len = binaryString.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    // eslint-disable-next-line unicorn/prefer-code-point
    bytes[i] = binaryString.charCodeAt(i);

  return bytes;

const SupplementImage = ({
}: {
  readonly image: Image;
  readonly loadingStrategy: LoadingStrategy;
}) => {
  const [dataUrl, setDataUrl] = useState<string | null>(null);

  useEffect(() => {
    requestIdleCallback(() => {
  }, []);

  return (
        aspectRatio: `${image.width} / ${image.height}`,
        backgroundImage: dataUrl ? `url(${dataUrl})` : undefined,
        backgroundSize: '100% 100%',

For context, thumbhash is a lot more compact representation of the image. Using the example above, the thumbhash is only a/cNG4L1NUOKhahX+XncHfg=.

This further reduces the size of the payload sent to the client, and because the image is generated inside requestIdleCallback, it does not block the main thread.

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.,

(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.


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.


The Actual Image
Thyroid T-3 Supplement Triptych

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:

<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. 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). That's a lot!


The realization that I had was that, just how I use 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:


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:

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
    .toBuffer({ resolveWithObject: true });

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

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

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.

⬆ Back to top
Supplement Research and Comparison Website: evidence-based information about supplements, their benefits, potential risks, and their efficacy.
Receive updates about our products, services, sales, and special offers. Unsubscribe anytime. See our Privacy Policy for details on how we handle your information.

Join Our Community

Use to get in touch.