DEV Community

Cover image for Compatible, Reliable and Experimental Image Lazy-loading in Web Browser
Mirek Ciastek
Mirek Ciastek

Posted on

Compatible, Reliable and Experimental Image Lazy-loading in Web Browser

In the last few years, web developers have become more aware of performance and loading issues in web applications. The complexity of web apps has increased so much, that making an application overwhelm a user’s device is pretty easy. Luckily for us, browser vendors noticed this risk, and they started to educate us about how to care more about performance and why it’s important for web users.

Getting the best performance and load times can be very challenging. We can minify, split and load on-demand scripts and styles, but there are other resources that we can’t split and they are usually very important for us.

Images, the topic of this article, are often very heavy resources, especially if we aim to provide the best quality content for high-resolution screens. Imagine that you need to load all the content of a very long page, full of scripts, complex styles and high-resolution images in less than 1 second on average? How you would tackle it?

What is lazy-loading and why it’s useful

You probably know that loading on demand can be the right path to speeding up your website. The rule is simple, if it’s not used don’t load it. Everything that is not visible or not used by the app, should be loaded later. This allows you to save some bandwidth and CPU cycles for more important stuff, like delivering the main content as fast as possible or fetching necessary data.

Here comes lazy-loading, a well-known technique of loading images only when they are required. To make use of lazy-loading properly, you need to define what resources are critical. The basic criterion is “above the fold”. In other words, if you want to know what needs to be loaded, just check if the resource is used or present in the viewport at the first load.

How lazy-loading is done today

Modern web technologies give us plenty of possibilities on how to deal with lazy-loading. The task seems to be very easy, we simply need to check if a certain image is present in the browser’s viewport, which means the user sees the image at the right moment.

To make an image lazy-load, first we need to disable automatically loading the image file by the browser. Simply, we replace src attribute with its equivalent data-src. Here’s a sample markup, that we can use in our lazy-loading feature.

<img data-src="path/to/image.png" alt="" />

Now, I would like to show you three approaches of implementing lazy-loading in your web app — a compatible, a reliable and an experimental approach. Let me break them down in the next sections.

The compatible approach

As we know, the main criterion for lazy-loading an image is its visibility in the viewport. A task that seems to be pretty simple, but requires some computations to be done, like calculating an element’s bounding box, the size of the viewport and the position of the element relative to the viewport.

First, we need to get an element’s bounding box measurements using the getBoundingClientRect method. Preferably we should do it once, on the first load, as constant reading can hurt performance. Next, we should check if any part of the element is present within the viewport’s coordinates. Lastly, we need to repeat the previous steps in a loop, to get the result in real-time. Let’s see the code.

First, the image loading handler.


const loadImage = (src) => {
  const img = new Image();

  return new Promise((resolve, reject) => {
    img.onload = () => resolve(src);
    img.onerror = reject;

    img.src = src;
  });
};

Then, let’s take care of checking element’s presence in the viewport.

const isInViewport = ({ top, height, windowHeight }) => {
  const scroll = window.scrollY || window.pageYOffset;
  const boundsTop = top + scroll;

  const viewport = {
    top: scroll,
    bottom: scroll + windowHeight,
  };

  const bounds = {
    top: boundsTop,
    bottom: boundsTop + height,
  };

  return (bounds.bottom >= viewport.top && bounds.bottom <= viewport.bottom)
    || (bounds.top <= viewport.bottom && bounds.top >= viewport.top);
};

Finally, we put everything in the scroll event handler and update measurements on demand.

import throttle from 'lodash/throttle';

const images = [...document.querySelectorAll('img')];

let windowHeight = window.innerHeight;

// We need to store images' sizes in a WeakMap
// to get them later in scroll handler
const imagesSizes = new WeakMap();

// This method allows to get top and height of each image
// and store them in WeakMap
const getImagesSizes = () => {
  images.forEach((image) => {
    const { top, height } = image.getBoundingClientRect();
    imagesSizes.set(image, { top, height });
  });
};

const onScroll = () => {
  images.forEach(async (image) => {
     // If image has been already loaded, bail out
     if (image.classList.contains('loaded')) {
       return;
     }

     const { top, height } = imagesSizes.get(image);

     // We use isInViewport method from previous example
     if (isInViewport({ top, height, windowHeight }) {
       try {
         // We use loadImage method from previous example
         await loadImage(image.src);
         image.classList.add('loaded');
       } catch (error) {
         console.error(error);
       }
     }
  });
};

// When window dimensions changed, update sizes
const onResize = () => {
  windowHeight = window.innerHeight;
  getImagesSizes();
};

getImagesSizes();

window.addEventListener('scroll', throttle(onScroll));
window.addEventListener('resize', onResize);

The scroll event listener is the most convenient way of checking an element’s visibility for any user’s interaction. What’s more, it’s a natural way of doing it, because the page needs to scroll if a user wants to see the next images.

You can imagine that performing any complex computations or operations in a scroll handler can easily kill your app. There are a few tricks that I used in my example, which helps avoid such mistakes. You probably already know the throttle method (check lodash docs), which decreases the number of a function’s calls. Additionally, I decided to read an element’s dimensions once on load (using WeakMap as a storage), and then update them only on a resize event to avoid too many requests for size computations.

This approach gives you the best support in comparison to others, but it’s not that easy to implement. Luckily it’s not the only way to do it, because recently we’ve got a nice new API which simplifies this process a lot.

The reliable approach

I’m pretty sure that you’ve heard about Intersection Observer API. This API has been around for about a year and is already supported by all major modern browsers (according to “Can I use” stats). What’s more, the Intersection Observer API is an Editor’s Draft. This means that shortly it will become a recommendation, which I’m really looking forward to.

What Intersection Observer does is observe if any part of a certain element is visible in the viewport. It works similarly to our custom script based on scroll but does it better, with less performance impact and in fewer lines. Let’s take a look at the example.

const images = [...document.querySelectorAll('img')];

const onIntersection = (entries, observer) => {
  entries.forEach(async (entry) => {
    if (entry.isIntersecting) {
      try {
        // We use loadImage method from previous example
        await loadImage(entry.target.src);
      } catch (error) {
        console.error(error);
      } finally {
        // When image has been loaded
        // stop observing the image
        observer.unobserve(entry.target);
      }
    }
  });
};

const observer = new IntersectionObserver(onIntersection);

// Start observing every image
images.forEach((image) => observer.observe(image));

You will have noticed that with Intersection Observer we don’t need to write any code for checking if an element is in the viewport. We simply use isIntersecting flag, and in the if block we run our image loader, from the previous section. You only need to remember about removing elements from the observed collection, just after the image is loaded. Moreover, I recommend using only one observer for all images in the active page.

Intersection Observer is something that was first seen a few years back. It facilitates working with lazy-loading patterns and it has a developer-friendly API. You may think that there’s nothing better out there… well, there is, but it’s still an experimental technology.

The native approach

Lazy-loading is such a common solution that browser vendors decided to build it into browsers. The result of their efforts is loading attribute - a native way of lazy-loading resources (not only images but also iframes). This proposal makes lazy-loading even more straightforward to implement than using the Intersection Observer API. At last, we don’t care about how it’s working, we can just use it, just like any other HTML feature.

Unfortunately for us, this attribute is only currently supported in Chrome 76+ (refer to “Can I use” table). There is a long way to go before it is an accepted standard or even a recommendation.

This is how the future of lazy-loading in browsers looks like.

<img src="path/to/image.png" loading="lazy" alt="" width="500" height="350">

Recommendations

I presented you with three approaches to implement lazy-loading images in web applications. All of them have pros and cons, but there is no single solution for this problem. Nevertheless, there is one that I can recommend to you in good conscience.

Between all presented solutions, I’d recommend using Intersection Observer with polyfill for old browsers. Intersection Observer API is the one that gives you a reliable solution with simple usage, albeit not being supported by all browsers. If you target modern browsers you don’t need to worry about that support, otherwise, use a polyfill to get better coverage.

I decided not to recommend the first and last approach, because the first one seems to be unnecessary, especially when Intersection Observer has acceptable browser support. Finally, loading attribute is still an experimental feature. It is supported by only one browser on the market and doesn’t give you enough flexibility compared to the first and second approach.

Further reading

Lazy-loading is a must-have pattern in modern web development. Thanks to the effort of browser vendors, we’ve got better tools for using this pattern in our applications, that’s practically free. Hopefully, in the near future, we won’t need to care too much about how lazy-loading works and we will be able to use it as a native feature in the browser, just like any other.

Finally, if you’re interested in this topic, I encourage you to check out the articles and tools listed below:

Top comments (0)