Improving the performance of images on the web

I’ve been benchmarking the performance of my blog for a while now, and there's no doubt that the cover images I'm using are going to keep having a negative impact on the overall interactivity of the blog. I want to change that.

Although, I've already tried — and I'm still using — an image CDN like Cloudinary to automate the process of optimizing these images on the fly.

I still want to retain the quality, and also convert these images to modern image formats like WebP and AVIF which are acceptable on web browsers that support these said formats. I ended up having a URL like the one below;

https://res.cloudinary.com/meje/image/upload/f_auto,q_70/v1668161028/image.jpg

Cloudinary provided a way for me to convert the images to modern image formats with the f_auto flag, and the q_auto flag to set the quality of the image without having to manually compress the image by myself. In the link above, you'll notice that I appended a value — 70 — to the q flag, to reduce the quality.

The tradeoff of this approach is that, as you're "optimizing" for faster page loads, and a very good TTI — Time To Interactive — score, the more you reduce the quality of these images, you're going to get less "enormous network payload" errors.

What I am saying here, in essence, is, when you reduce the quality of an image, You somehow reduce the size, this is probably a result of the compression algorithm that is being used here.

There are Lossless image compression algorithms that you can use out there too, without trading the quality of your images for lesser network payloads.

I'd say that a proper OR recommended approach towards optimizing images for the web is to manually compress them with compression tools like TinyJPG or Squoosh before uploading them to your favorite image CDN. Why? you'd ask me.

This is because after you've compressed your images with a tool that doesn't use a Lossy image compression algorithm, you'd still retain the quality of your images and a substantial reduction in the size of your image without losing the important components of that image's data.

It is a win-win situation for everyone.

Serving images locally.

Another approach would be to have all the images you want in the same codebase/repository so you can access them without making unnecessary fetch calls to a remote server.

But, the important thing to note when using this approach would be the manual optimization of these images before you use them. So, the method of doing this would be similar to how we normally reference images in plain 'Ol HTML with the image element.

<img src="public/image-name.png|jpg|webp|avif" alt="image description" />

Since this approach of serving images locally can help eradicate the complexities of preconnect-ing to the domain of the Content Delivery Network (CDN) of an image service provider like Cloudinary and its other counterparts: ImageMin, Akamai, etc.

It provides a better way to avoid unnecessary large network payloads and reduces the time taken for these requests to be processed by the browser, hence, delivering an optimal experience to the users of your website.

When you don't use the "preconnect" value of the rel attribute in the <link /> element, the browser isn't completely aware of the appropriate magnitude of importance to give to these images when fetching them.

<link rel="preconnect" href="res.cloudinary.com" />

Prioritizing the requests of image assets.

There's an update in the latest web standards that allows us to add a fetch priority to images.

This idea was also implemented in the native fetch API of the browser, the idea behind this was to be able to manipulate or re-order the way various browsers parse and fetch the resources of a web page.

And, as images are a vital part of any website that uses them, they have to be given utmost importance.

An example of this use-case is with the LCP — Largest Contentful Paint — element of a website, which happens to be an image element most of the time.

<img src="public/image-name.png" alt="image description" priority="high" />

In Frontend frameworks like Next.js the next/image component accepts a boolean fetchPriority prop, Take a look at it below;

import React from 'react'
import Image from 'next/image'
import imageSource from 'path/to/image'

export default function ExampleComponent() {
  return <Image src={imageSource} height={100} width={100} priority={true} />
}

The priority hint feature ensures that browsers fetch resources that are important for the usability of software — web apps & websites — on the web, and these priority hints can be used when we're carrying out native fetch operations to speed up the process of getting the image resources.

Aside from the fact that we're already preconnect-ing to the image service provider's CDN. The priority object can receive any of these values: high, low, and an empty string.

The absence of a priority property in the fetch options resolves to the default method of fetching the images.

An example of this in action can be seen in the snippet below, where I'm utilizing the API routes in Next.js to generate a blurred version of the images on my blog as placeholders.

You'll notice how I assigned the "high" string to the priority property.

export const imageDataURL = async (url) => {
  const data = await fetch(url, { priority: 'high' })
  const blob = await data.blob()

  return new Promise((resolve) => {
    const reader = new FileReader()
    reader.readAsDataURL(blob)

    reader.onloadend = () => {
      const blurData = reader.result
       resolve(blurData)
      }
    })
  }
}

Rendering responsive images

To further reduce the performance implications caused by images on a website, you'd need to start considering an approach that allows you to build for everyone.

Everyone in the sense that you'd need to factor the devices of the users of your app into your process, as you do not want to ship an image that does not correspond with the DPR — Device Pixel Ratio — of your users in production.

Say an image has a dimension of 350px by 768px, and you're sending this image as is, to the user on a mobile device with a viewport dimension of 260px by 550px.

This means that the mobile browser of the user's device would still have to download that image with an aspect ratio that does not correspond with its viewport dimensions, and if I'm not wrong, the dimensions of an image have an effect on the image's size.

When you ship images that are not responsive — responsive in this sense doesn't have to do with the layout of such images.

It refers to how the dimensions of an image are appropriately returned while considering the DPR of your end users — your website has a sudden, and unfavorable impact on the mobile data plans that they're currently on because they'd have to download the image that is not pruned down to their current device width.

The conventional way of accomplishing this feat would be to have multiple images, for various viewport breakpoints, with different dimensions, and in turn pass them in the srcset attribute of the native <img /> element, like it is in the snippet below.

But, for this to work appropriately, you'd need to add the sizes attribute in the image element too.

<img
  src="image.jpg"
  alt="image description"
  sizes="(max-width: 768px) 100vw, (max-width: 1200px), 50vw, 33vw"
  srcset="
  image,w_200.jpg 200w,
  image,w_591.jpg 591w,
  image,w_882.jpg 882w,
  image,w_1056.jpg 1056w,
  image,w_1259.jpg 1259w,
  image,w_1344.jpg 1344w,
  image,w_1400.jpg 1400w"
/>

The sizes attribute in the snippet above can be used for layouts that have a list of cards with images in them in a 3-by-3 pattern. A vivid example can be seen on my blog, you can also take a look at the image below;

Caleb Olojo's blog showing various articles he has published in the past

At a max-width of 768px, the image spans the full width of its parent container, and on desktop screens, or larger screens, it takes half the width of the viewport, with respect to the parent container.

You're probably wondering: "So, I am expected to manually create different images for different viewports? All this stress just for the benefit of an end user?" Well, YES, all this stress for the benefit of an end user, and NO, you don't really have to manually create these images.

Cloudinary has a responsive image breakpoints generator that you can use for this sole purpose.

All you have to do is upload the image of your choice, and set the appropriate breakpoints in the UI, and it'll generate the markup that you can add in the srcset attribute.

Compatibility with Frontend frameworks

I'm pretty sure the process of creating images that corresponds properly with the Device Pixel Ratio (DPR) of mobile devices is somewhat similar to how it would be done with the native HTML <img /> element.

But, the case is different when creating these images in a typical Next.js app.

Although there's an approach that can be followed through from their docs, it requires you to edit the next.config.js file with an array of deviceSizes and imageSizes properties like the one below;

module.exports = {
  images: {
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
}

The snippet above is the default configuration, if you don't specify them explicitly in your config file, Next.js falls back to that config.

But, the catch here is, even when I didn't specify these properties in my config file, one would think everything would work fine, right? Well, I was wrong, when I checked my blog's page speed insight, I kept getting this error below, despite the fact that I've also added my array of images via the srcSet attribute.

page speed insight of Caleb Olojo's blog

I found out from a discussion that I started in the Next.js GitHub repo that the srcSet propType was omitted because Next.js automatically optimizes your images for you.

Still, their optimization approach did not suit my use case, since I have already optimized my images with Cloudinary, I needed a way to ship images with their appropriate sizes on various device widths.

The snippet below is an excerpt of how the srcSet attribute is ignored in the Next.js image component.

if (unoptimized) {
  return { src, srcSet: undefined, sizes: undefined }
}

const { widths, kind } = getWidths(config, width, sizes)
  const last = widths.length - 1

  return {
    sizes: !sizes && kind === 'w' ? '100vw' : sizes,
    srcSet: widths
      .map(
         (w, i) =>
          `${loader({ config, src, quality, width: w })} ${
               kind === 'w' ? w : i + 1
           }${kind}`
          )
         .join(', '),
   }
}

And a way to fix this would require you to set the boolean propType, unoptimized to true, in the Next.js image component, like so:

export default function Card({ data: { title, cover_image, imageSet } }) {
  return (
    <Wrapper>
      <div className="cover-image">
         <Image
           alt={title}
           src={cover_image}
           height={65}
           width={100}
           layout="responsive"
           placeholder="blur"
           blurDataURL={blurHash}
           priority={true}
           srcSet={imageSet}
           sizes="(max-width: 768px) 100vw, (max-width: 1200px), 50vw, 33vw"
           unoptimized={true}
         />
      </div>
    </Wrapper>
  )
}

PS: This approach of serving responsive images may have changed since the release of Next.js 13, and there’s been a vital improvement to the Image component. Do check it out first, before you try using this approach.

Improving Cumulative Layout Shifts

One more way to add a tiny improvement to the performance of your images would be to consider greatly, the CLS — Cumulative Layout Shift — that these images themselves, cause, on initial render.

This performance metric is among the many metrics that reduce your lighthouse score and somehow affect your page speed insights too.

An approach to fixing this image performance issue would be to use the aspect-ratio CSS property on the img element.

The property determines the ratio between the image’s height and width property — a bit related to the Device Pixel Ratio you saw in the previous section — instead of explicitly setting the exact dimensions of the image.

img {
 aspect-ratio: 1/1;
 width: 100%;
}

You can read more about the values the property receives here

The good thing about this approach is that, before the image resource(s) is fetched from the remote server, the browser gives a ratio of the space the image is expected to occupy.

So if you have an image element in a div with texts above and below it. The image’s space is reserved as is, until it is completely fetched by the browser.

Since the goal to design and build for everyone should be our priority as Engineers, one way to sort of provide a good user experience for people would be to add a perception for the person visiting your site that a particular image is still loading.

Rather than having a large space in the component, you can set a background for the image, so that people are aware that an element will be there in no time.

The image below illustrates what it looks like on my blog.

Caleb Olojo's blog showing a an image preloader for al his articles

With the Next.js image component, you may not need to specify an aspect ratio when you’re styling the component, all you may just need to do, is add a background to the element.

Final Thoughts

Optimizing your images before using any image CDN can help reduce those enormous network payloads, and by doing so, it reduces the time taken for your page to be interactive.

I sometimes, refer to this process as having multiple layers of optimization and or compression of these images to modern web image formats.

A great tool I’ll recommend is Squoosh because it provides a lot of customization for you when you want to optimize your images with its lossless image compression algorithm.