Lazy loading: How to implement it on your website

As discussed in a previous article about improving site performance, this article delves a bit deeper into the technical aspects, demonstrating how to actually add lazy loading images to your site or application.

Quick recap of the problem we are solving

When a page is loaded, by default the browser will automatically download every image it finds on the page regardless of whether or not the image is in view. So not only does this effect the speed at which the page loads, it also forces users into using up unneccessary data allowances.

So how can we solve this? 

This is where the Intersection Observer API comes to the rescue. So what exactly is this and how can it fix our problem?

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.

Src - MDN Web docs

The Intersection Observer API allows developers to use a few lines of JavaScript to detect when a specific element is in view within the users screen. In our case we can utilize this detection to only load the images on the page when they are visible to the user. 

So in the next section lets actually implement the Intersection Observer to lazy load images in an example. As mentioned in the previous article ‘Speed up your site by making it lazy’, we discussed browser support and the requirement for fallbacks. At the time of writing Intersection Observer is supported in the latest versions of Chrome, Firefox, Microsoft Edge and currently not supported in Internet Explorer or Safari, however the team at Webkit are currently developing support for Safari, so our example will include a fallback.

Lets create our example

Our example will be a simple page that includes several images, each contained within a wrapper that takes up the full height of the screen, this way we can demonstrate the requirement to scroll down to each and load them as required.

Our basic HTML structure contains 4 images, each with a class of ‘js-img’ so we can target them in Javascript.

<div class="image-wrapper">
    <img class="js-img" src="img/mountains.jpg" alt="Mountain scenery overlooking a lake">
</div>
<div class="image-wrapper">
    <img class="js-img" src="img/barney.jpg" alt="Profile of our dog Barney">
</div>
<div class="image-wrapper">
    <img class="js-img" src="img/surnise.jpg" alt="Sunrise through the corn">
</div>
<div class="image-wrapper">
    <img class="js-img" src="img/edinburgh.jpg" alt="Carlton Hill in Edinburgh">
</div>

So when rendered in the browser we only see the first image displayed. If we open up the Network Tab within Chrome dev tools and see what’s happening behind the scenes, notice the section highlighted in the red box.

Page screenshot showing network tab open

Although only the first image is visible on page load, the other three images have actually also been downloaded. Lets fix that.

We need a way to tell the browser not to download any images until we tell it to, so how can we achieve this? We can change the ‘src’ attribute of the image to be a data attribute instead, this way when the browser finds the image tag in the HTML it will not render the image, and then when we want to load the image, change it back to a ‘src’ attribute which will display the image.

So obviously in order for all this to work the user needs Javascript enabled in their browser. Although it's very unlikely that a user will have Javascript disabled, if this concerns you then a simple solution could be to display a message informing them to enable it to view images, or alternatively use <noscript> tags and display images as normal, however both of these are out with the scope of this article.

Lets update all our image elements to use data-src to hold the path to our image instead of src.

<div class="image-wrapper">
    <img class="js-img" data-src="img/mountains.jpg" alt="Mountain scenery overlooking a lake">
</div>
<div class="image-wrapper">
    <img class="js-img" data-src="img/barney.jpg" alt="Profile of our dog Barney">
</div>
<div class="image-wrapper">
    <img class="js-img" data-src="img/surnise.jpg" alt="Sunrise through the corn">
</div>
<div class="image-wrapper">
    <img class="js-img" data-src="img/edinburgh.jpg" alt="Carlton Hill in Edinburgh">
</div>

So now when the page loads no images will be downloaded. Time to write some Javascript and use our Intersection Observer to detect when the images can actually be loaded and displayed. 

First we store a reference to all the images on the page.

const images = document.querySelectorAll('.js-img');

Now lets create our Intersection Observer instance and store in a variable called observer. This takes in two arguments, a callback function, and settings. We will store our settings in a variable called config to pass in. In the settings we can specify a margin for the detection area as well as a threshold for the percentage of the image to be visible before firing the callback.

const config = {
    rootMargin: '100px 0px',
    threshold: 0.01
};
const observer = new IntersectionObserver(observeImages, config);

images.forEach(image => {
    observer.observe(image);
})

In our example we are telling the observer to detect the images when they are 100px from the viewport and a threshold of 1% to be visible, so basically as soon as we detect the image fire the callback.

We then loop over each image and tell our newly created observer to observe each one. *note - I have used vanilla Javascript and a forEach() loop for the purpose of this demo, if you are developing for older browsers be sure to check compatibility as if not supported a for() loop would also work, or .each() if you're using jQuery.

Now that we have our observer set up listening for the image detection we need to create our observeImages function. This callback function receives a list of target objects as a parameter, so we need to loop over each and check if it is in view. If its in view, load the image. 

function observeImages(entries) {
    entries.forEach(entry => {
        if(entry.intersectionRatio > 0) {
            observer.unobserve(entry.target);
            entry.target.src = entry.target.dataset.src;
        }
    })
}

When the observeImages callback is fired, each target passed in is an object containing data we can drill down into. The value we need is its intersectionRatio which returns a value between 0 and 1, with a value greater than zero meaning it is in our detection area. We can test this by logging out the value of each entry to the console. You can see from the example below two of the targets, the top one has a value of zero as its not yet in view, the bottom one is now in view and can be loaded and displayed.

screenshot from developer console

So back to our function, if the target is in view, we can stop observing the target, and then update the target's src attribute to be the data attribute which contains the path to the image.

Although this will work, we can still improve our observeImages function. A nicer method is to first check the image exists and the path set is correct. We can do this using Javascript promises.

So lets create a preload function that takes in the data attribute as a parameter, and returns a Javascript promise. We will load a new image object using this path and only resolve the promise if the image loads correctly passing back the url.

function preloadImage(url) {
    return new Promise((resolve,reject) => {
        let image = new Image();
        image.load = resolve(url);
        image.error = reject;
        image.src = url;
    })
}

Back in our observeImages function we can call this function and only update the src attribute of the target once the promise has resolved successfully. At this point the url path is returned and can be accessed by chaining a .then() to our preload function. As before we then update the src attribute.

if(entry.intersectionRatio > 0) {
    observer.unobserve(entry.target);
    
    preloadImage(entry.target.dataset.src).then(src => {
        entry.target.src = src;
        entry.target.removeAttribute('data-src');
    })
} 

The result

So lets look at the result in the browser. When the page first loads only the first image has actually been downloaded, and as we scroll down the next image is downloaded.

Page screenshot with network tab open

Screenshot of page with network tab open

Speed improvement

Lets test out the speed by using Chrome developer tools to simulate a really slow connection. In order to effectively test this I have set it up to simulate a slow 3G connection. The speed for both outcomes will be very slow, but it gives us a better indication of the difference in speed.

network tab results

Above are the speed stats without lazy loading. The load time for the entire page was 31.11 seconds

network tab results

Above are the speed stats with lazy loading. The load time for the entire page was 10.53 seconds

From analysing these stats we can see it took a third of the time to download everything when using lazy loading, and we also saved the user 1mb of data they didn’t need to download.

Adding a fallback for unsupported browsers

As mentioned the Intersection Observer API is not supported in Internet Explorer or as of yet Safari, so as a fallback we can check if the API is supported by the browser using if ('IntersectionObserver' in window), and if not we just load all the images automatically using our preloadImages function, but if it is supported we then use our Intersection Observer.

What about background images set in the CSS?

It's not only image elements that we can use this for. We can also target background images. The only difference is we add an extra check to determine the type of element the target is. If it’s an img tag then we update the src attribute, if not then we update the background image in the CSS instead.

preloadImage(entry.target.dataset.src).then(src => {
    entry.target.tagName === 'IMG' ?
        entry.target.src = src :
        entry.target.style.backgroundImage = 'url('+ src +')';
        
    entry.target.removeAttribute('data-src');
})

Further enhancing our lazy loading

A nice additional technique we can use is to add an animation to our images. This way when the user scrolls down the page and the images are loaded, they do not suddenly appear on the page, and instead transition in. We can easily set this up by adding an extra CSS rule for our images that applies a CSS keyframe animation.

.js-img.fade {
    animation-name: fadeIn;
    animation-duration: 400ms;
    animation-fill-mode: forwards;
    animation-timing-function: ease-in;
}        

@keyframes fadeIn {
    0% { opacity: 0; }
    100% { opacity: 1; }
}

We need to finally update our preloadImage function to add the animation class to the target when its in view.

preloadImage(entry.target.dataset.src).then(src => {
    entry.target.tagName === 'IMG' ?
        entry.target.src = src :
        entry.target.style.backgroundImage = 'url('+ src +')';
        
    entry.target.classList.add('fade');
    entry.target.removeAttribute('data-src');
})

Now when scrolling down the images will transition from zero opacity to full opacity as they are being loaded.

So lets put all the finished code together

I have refactored the preloadImage call into into it's own function called handleImage as depending on whether or not the Intersection Observer is supported, it will be called passing in different arguments both times, so this way we can avoid repeating code.

const images = document.querySelectorAll('.js-img');

if(!('IntersectionObserver' in window)) {
    images.forEach(image => {
        handleImage(image);
    })
} else {
    let config = {
        rootMargin: '100px 0px',
        threshold: 0.01
    };
    let observer = new IntersectionObserver(observeImages, config);
    
    images.forEach(image => {
        observer.observe(image);
    }) 
    
    function observeImages(entries) {
        entries.forEach(entry => {
            if(entry.intersectionRatio > 0) {
                observer.unobserve(entry.target);
                handleImage(entry.target);
            }
        })
    } 
}

function handleImage(el) {
    preloadImage(el.dataset.src).then(src => {
        el.tagName === 'IMG' ?
            el.src = src :
            el.style.backgroundImage = 'url('+ src +')';
            
        el.classList.add('fade');
        el.removeAttribute('data-src');
    })    
}

function preloadImage(url) {
    return new Promise((resolve,reject) => {
        let image = new Image();
        image.load = resolve(url);
        image.error = reject;
        image.src = url;
    })
}

And thats it. We have implimented lazy loading with a fallback in case there is no browser support for the Observer, which would simply load all the images as normal.


Speak to us if you have specific questions on how it can be added to your site, or visit our previous article on lazy loading below.