Over time several techniques have been established to help improve how images load on a page, both for speed and to improve the user’s experience. I was looking into improving the Largest Contentful Paint (LCP) of a page, and it was pointed out that one of these techniques (LQIP) was having a negative effect on the LCP score. LQIP typically improves the user experience, so I investigated to see if these techniques can also work nicely with LCP.

Disclaimer: I’m not a browser expert and know enough CSS to be dangerous. I’d love to hear from people who are more familiar with images and speed in browsers.

The Result

Let’s start at the end and show the difference that can be made with just HTML and CSS. The video on the left loads the images in the usual way. And the one on the right uses techniques to improve things. In this test, the reported LCP went from 6 seconds to 600ms. It also shows how techniques like LQIP can make the image loading experience feel much better, even though the page loads roughly at the same time.

These videos are provided by WebPageTest (the screenshot section) using their default settings.

Hat Tip

First, a hat tip to Dave Smart (@davesmart), who suggested the way I’m implementing LQIP after I mentioned the issue with LQIP and LCP.

The Test Page

I built an artificial page to test from. It consists of 19 images (that vary by a query string parameter) and some fake resources to cause some typical requests and delays as a page loads. You can test it yourself here.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>

    <style>
        .thumbnail {
            width: 365px;
            height: 274px;
        }
    </style>

    <script async src="script.js?p=head-async"></script>

    <script src="script.js?p=head-blocking"></script>
</head>
<body>
    <script src="script.js?p=body-blocking"></script>

    <img src="image-25.jpg?p=1" class="thumbnail" />
    <img src="image-25.jpg?p=2" class="thumbnail" />
    <img src="image-25.jpg?p=3" class="thumbnail" />
    <img src="image-25.jpg?p=4" class="thumbnail" />
    <img src="image-25.jpg?p=5" class="thumbnail" />
    <img src="image-25.jpg?p=6" class="thumbnail" />
    <img src="image-25.jpg?p=7" class="thumbnail" />
    <img src="image-25.jpg?p=8" class="thumbnail" />
    <img src="image-25.jpg?p=9" class="thumbnail" />
    <img src="image-25.jpg?p=10" class="thumbnail" />
    <img src="image-25.jpg?p=11" class="thumbnail" />
    <img src="image-25.jpg?p=12" class="thumbnail" />
    <img src="image-25.jpg?p=13" class="thumbnail" />
    <img src="image-25.jpg?p=14" class="thumbnail" />
    <img src="image-25.jpg?p=15" class="thumbnail" />
    <img src="image-25.jpg?p=16" class="thumbnail" />
    <img src="image-25.jpg?p=17" class="thumbnail" />
    <img src="image-25.jpg?p=18" class="thumbnail" />
    <img src="image-25.jpg?p=19" class="thumbnail" />

    <script src="script.js?p=foot-blocking"></script>
</body>
</html>

To test the page, I used Chrome Canary (98.0.4735.0) with the screen shrunk down to only display the first two images on top of each other, and the network throttled to Fast 3G. I then gathered data from the Network and Performance tabs.

A few things worth pointing out:

  • Images do not start loading until the blocking scripts above them are loaded
  • LCP happens once the first image is loaded
  • All images are loaded
  • The load event is after all the images have loaded

Lazy Loading

A first improvement would be to add lazy loading of images so that we reduce the number of images that initially get loaded. I’m using the native loading feature now supported for an estimated 75% of users. And it’s quite a simple change to each image:

<img loading="lazy" src="image-25.jpg?p=1" class="thumbnail" />

What About JavaScript Based Lazy Loading?

Because native lazy loading is new and not supported by all browsers, many sites use JavaScript libraries to implement lazy loading.

From what I’ve seen, they often don’t perform as well as the native implementation, which seems to be quite good at loading images before they enter the viewport.

Another issue with code-based lazy loading is that it delays the loading of images that are initially in the viewport. This is because the src is set only when the JavaScript decides to run (say on the DCL or Load event). And that means the images are then put at the end of the queue for loading. This delay is not good for LCP. So don’t add code-based lazy loading to images that are initially seen on load. This is not an issue with the native lazy loading.

To the effect of native lazy loading…

The most obvious change is that it does not load all 19 images, just 11 of them. This is because it will load images in the viewport and ones not too far from it. As you scroll, it starts to load more of the images.

A few other interesting changes. The images are all pushed down the queue due to their priority now being low. This meant my footer script got to run earlier, which improved the DCL time. And probably more important is that the Load event fires before the images are loaded, letting load-based scripts execute much earlier.

So I’d say the main benefit here is that it does not affect the user’s experience while freeing up the browser to do other things earlier.

LQIP via CSS

Low Quality Image Placeholders (LQIP) help improve the user’s perception of how an image loads. The page quickly shows a low-res (blurry) version of the image and then replaces it with the full image once it has loaded.

All the solutions I found were JavaScript based. And like the code-based lazy loading solutions, the loading of the full-size image is delayed causing a poor LCP.

Unlike the lazy loading solution, we do want to use LQIP on images that are initially visible in the viewport, as this is the whole point of LQIP. So it seemed that LQIP could not be LCP friendly. Until Dave Smart came along with his CSS solution.

<style>
.lqip {
   background-repeat: no-repeat;
   background-size: cover;
}
</style>
...
<img loading="lazy" src="image-25.jpg?p=1" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=1'); background-color: #D3C5BC;" />

I thought this was very clever. The LQIP image is loaded as a background image, which is covered by the full-size image once loaded. No complex JavaScript.

I first tested without the lazy loading and noticed that the main images started to load first, then the LQIP ones. Not much use if the LQIP images have to wait to be loaded. However, with lazy loading enabled, the main image is set to low priority, and the CSS images get loaded first.

I also made another user experience improvement, which was to set a background color for the image placeholder to a colour that matched the image’s dominant colour.

A complete solution would probably need some automatic way of generating the LQIP images and working out the dominant colors.

And this is where it gets interesting…

At the FP/FCP point, the user sees the dominant colors for the images. Giving them a rough idea of what is about to come.

Load is slightly delayed as it fires after the LQIP images are loaded (they are not lazy-loaded). At load, the user sees these blurry versions of the images, giving them early on a good idea of what the images are going to be.

And at some point later, the high-res versions are shown, and the user sees them in all their glory.

What’s interesting is that LCP is based on the quickly loaded LQIP images and not the full images, which are shown a long time later. Is this a mistake in the LCP calculation or deliberate?

Preload

There’s one more step that I took. LCP for pages like this is typically the first image in the list. Especially on smaller devices that may only show the one image at the start. So can we speed up that first image? Yes, with preload. I decided to preload the first LQIP and the first main image.

    <link rel="preload" href="image-1.jpg?p=1" as="image" fetchpriority="high">
    <link rel="preload" href="image-25.jpg?p=1" as="image" fetchpriority="high">

Preloads should be high up on the page so that they get first dibs at starting their requests. And for good measure, I set the fetchpriority to high (about priority hints), which will stop the image from using up a low priority spot.

I also removed the lazy loading from the first image. We don’t want mixed messaging:

<img src="image-25.jpg?p=1" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=1'); background-color: #D3C5BC;" />

It was not quite what I expected:

The first images did start loading earlier, but the change pushed out the LCP and the Load events. It also delayed some scripts from loading and the DCL event. So a better user experience seemed at the cost of LCP and Load times.

The video of the final result is based on this setting. So using LQIP, lazy load on all but the first image and two preloads. Not sure why WebPageTest reported such a good LCP; maybe the difference between browsers and emulation modes. Here’s the test page.

I played around, and for my Chrome Canary, I found the best LCP result was to drop preloading the main image and put the lazy load back on it.

In this scenario, the LCP was again based on the first LQIP image, which is the first thing loaded on the page.

At this point, I felt I was trying to game a specific browser engine and its quirks (Chrome and Edge did behave in the same way) that let me cause the LCP to tie itself to the fast loaded LQIP image. So I’ll stick to what I think is the best for the user and preload the main image. Here is the complete code:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>

    <style>
        .thumbnail {
            width: 365px;
            height: 274px;
        }
        .lqip {
            background-repeat: no-repeat;
            background-size: cover;
        }
    </style>

    <link rel="preload" href="image-1.jpg?p=1" as="image" fetchpriority="high">
    <link rel="preload" href="image-25.jpg?p=1" as="image" fetchpriority="high">

    <script async src="script.js?p=head-async"></script>
    <script src="script.js?p=head-blocking"></script>
</head>
<body>
    <script src="script.js?p=body-blocking"></script>

    <img src="image-25.jpg?p=1" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=1'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=2" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=2'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=3" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=3'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=4" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=4'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=5" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=5'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=6" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=6'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=7" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=7'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=8" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=8'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=9" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=9'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=10" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=10'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=11" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=11'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=12" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=12'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=13" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=13'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=14" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=14'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=15" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=15'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=16" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=16'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=17" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=17'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=18" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=18'); background-color: #D3C5BC;" />
    <img loading="lazy" src="image-25.jpg?p=19" class="thumbnail lqip" style="background-image: url('image-1.jpg?p=19'); background-color: #D3C5BC;" />

    <script src="script.js?p=foot-blocking"></script>
</body>
</html>

Backward Compatibility Using Lazysizes

Some browsers like Safari do not currently support the native lazy loading feature. Lazy loading, in that case, is often done using a JavaScript library like lazysizes. The web dev team suggested a way to detect if native lazy loading is present and use lazysizes if not. A nice idea, but I did not like how the code had to be at the end of the page, meaning that the image’s src would not be set until late. Here’s a variation on their idea that sets the src as soon as the img tag is parsed. You place this code at the start of the head before loading any blocking resources so that it runs instantly:

    <script>
        if ('loading' in HTMLImageElement.prototype) {
            var observer = new MutationObserver(function (mutations_list) {
                mutations_list.forEach(function (mutation) {
                    mutation.addedNodes.forEach(function (addedNode) {
                        if (addedNode.nodeType === 1 && addedNode.tagName === 'IMG' && addedNode.classList.contains('lazyload')) {
                            addedNode.loading = "lazy";

                            if (addedNode.classList.contains('loading-lqip')) {
                                if (addedNode.src) { addedNode.style.cssText += "background-image: url('" + addedNode.src + "');" };
                                if (addedNode.srcset) { addedNode.style.cssText += "background-image: url('" + addedNode.srcset + "');" };
                            }
                            if (addedNode.dataset.src) addedNode.src = addedNode.dataset.src;
                            if (addedNode.dataset.sizes && addedNode.dataset.sizes !== 'auto') addedNode.sizes = addedNode.dataset.sizes;
                            if (addedNode.dataset.srcset) addedNode.srcset = addedNode.dataset.srcset;
                            addedNode.classList.remove('lazyload');
                            addedNode.onload=function(){addedNode.classList.add('lazyloaded')};

                            // support other scenarios like picture/source ?
                        }
                         
                    });
                });
            });

            observer.observe(document.documentElement, { subtree: true, childList: true });
        }
        else {
            // Option to dynamically import the LazySizes library.
            var script = document.createElement('script');
            script.src = 'https://cdnjs.cloudflare.com/ajax/libs/lazysizes/5.1.2/lazysizes.min.js';
            document.getElementsByTagName('head')[0].appendChild(script);
        }
    </script>
    <style>
        .loading-lqip {
            background-repeat: no-repeat;
            background-size: contain;
            background-position: center;
        }
    </style>

The code will set a background image if an initial src or srcset is defined. This way, LQIP can be supported. If you apply the responsive-image class to your images, you can control how that background image displays.

HTTP/1.1 v H2

At one point, I was testing using the HTTP/1.1 protocol instead of the latest H2 version (my localhost). In HTTP/1.1, the browser only makes about six requests at a time. While in H2, it makes a whole bunch. That can mean more significant delays in page loading, and the order of loading resources is far more important.

Using lazy load to ensure the LQIP images load first is critical. And lazy load also reduced the number of images loading (in bunches of 6), thus massively cutting down the load time. And in this case, the preloaded main image was the LCP, so preloading also had a benefit, especially if there were a lot of resources before the main image.

Image File Types

I had a bit of a play with file types. The main tests were using jpeg at 90%, resulting in a 191kb file. webp slightly improved on the compression at 181kb and avif (not well supported) did even better at 137kb.

I also tried progressive jpeg that slightly reduced the size to 181kb and caused a neater transition from blurry to sharp.

Responsive Images

In these tests, the images were bigger than the size they were shown. Partly to help emphasises the download time. In the real world, you want the image to perfectly fit the space it will be shown in, which can vary per device. e.g. a mobile phone may show smaller images than the desktop version.

Responsive images using srcset/sizes, picture>source or client hints are ways to have the browser load a version of the image that perfectly fits the space used on the device requesting it. This can further speed up image loading, especially on smaller devices.

Tip: If you are using srcset/sizes and preload for an image, use imagesrcset/imagesizes on the preload so that it loads the correct image (Preloading responsive images).

Using client hints can avoid the need to use srcset or picture>source and reduce code bloat. The server can use client hints about the size of the device to pre-pick the image versions that the page will load. However, it is a more technical solution to set up.

Save-Data and Effective Connection

Save-Data and Effective Connection can be read on the server via client hints or the browser via navigator.connection.

Save-Data is a user preference in the browser to indicate that they want less data to be sent. You could use this knowledge to automatically reduce the amount of data needed by a page.

Effective Connection is a hint about the network speed the user has: 4g, 3g, 2g, and slow-2g. This could also be used to reduce data requirements if the user is on the equivalent to, say, a 2G network.

prefers-reduced-data is an experimental CSS media feature that may take the idea of Save-Data into the CSS and media attribute realm.

Conclusion

I think I have found a way (with help from Dave) to provide a better experience for the user in both the way images show up and how fast the experience feels. All this while avoiding JavaScript and other complexities.

I have used modern browser features like native lazy loading. In all cases, there should be a graceful degradation on older browsers. It would be possible to detect missing features and use JavaScript as a backup on those browsers.

As I mentioned at the beginning, this is not my area of expertise. I’d love any feedback, even if it’s to say this is a load of rubbish.

Leave a Reply

Your email address will not be published.