Lazy loading is a technique that defers loading of off-screen images at page load time. This can drastically decrease the initial payload, without affecting the above-the-fold content. In this article I’m going to use the new Intersection Observer API to lazy load images, with graceful degradation for older browsers that don’t support the technology.

Let’s take a look at the end result, before walking through the implementation.

Lazy Loading (The Old Way)

To really see the benefit of the intersection observer, it’s worth looking at its use cases and comparing them to alternative solutions. In order to lazy load an image, we need to detect when an element enters the viewport, and invoke a function to handle the displaying of it. A quick google on the topic and you’ll see a lot of solutions that involve a window scroll (and resize function).

A typical jQuery solution might look like this;

$(window).on(‘resize scroll’, function () {
  /*
  1. Check each image to see if it is visible in viewport
  2. Load the image
  */
});

Although this would work, there’s a few flaws with this approach;

  1. Invoking a function for every pixel a user scrolls will kill your performance. You could throttle or debounce the function, but it’s still going to perform much worse than our fancy new Intersection Observer.
  2. A lot of the function calls on scroll/resize are likely to be superfluous, and not actually load any images. That’s because the function would have a conditional statement which checked every image element and it’s offset positioning relative to the viewport (using something like getBoundingClientRect()). That’s going to be a costly check, and one which will likely return false a lot more often than desired.

The Intersection Observer API

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.
MDN – Intersection Observer API

The introduction of this Web API has now made it incredibly easy, and performant, to configure a callback function to fire whenever a target element intersects the viewport. So let’s take a look how it works, and how simple it’s now made lazy loading images.

Create the intersection observer by calling its constructor and passing a callback function, along with an object of options.

const options = {
  root: null,
  rootMargin: '0px',
  threshold: 0.5
}

const observer = new IntersectionObserver(callback, options);

There are three available options you can optionally define;

  • root – You can specify an element used for checking the visibility of the target. Keep in mind this root element needs to be an ancestor of the target. This option defaults to the browser viewport if not specified, or if null.
  • rootMargin – You’ve probably guessed it…it’s the margin around the root. You can use unit values that the margin CSS property supports (e.g. px, em, %) and you can also use the same shorthand syntax (e.g. “5px 10px 15px 20px” – top, right, bottom, left).
  • threshold – This option is really useful. You can define a value between 0 – 1 to specify at what percentage of the target’s visibility should it cause the observer’s callback to be executed. So in the code above 0.5 means we want to detect when the target’s visibility passes the 50% mark. This option can also take an array of numbers, but I recommend you check the MDN docs for more detailed information on that.

Now we have instantiated an observer, let’s look at how to target child elements to be observed.

const image = document.getElementById('myLazyImage');
observer.observe(image);

This code targets an element with the ID myLazyImage. Now when this image is visible more than 50% in the viewport (due to our threshold 0.5), the callback function will be invoked.

The callback function receives a list of IntersectionObserverEntry objects and the observer as its arguments. We will look into these below in our lazy loading code.

Now we know the basics of the Intersection Observer API, let’s put it into action and build our lazy loading images script.

Performant Lazy Loading of Images

HTML Markup

Let’s start with our HTML markup. To prevent images from being requested in the initial payload we need to omit the src attribute.

<img data-src="https://picsum.photos/300?random" alt="" />

I like to use a data-src attribute when lazy loading images, as it makes it clear what the purpose of the data attribute is. Once we’ve loaded the image, we will remove this data attribute in place of the proper src attribute.

JavaScript Implementation

Our first step in JavaScript is to setup our intersection observer. Now we also want to cater for older browser that don’t yet support our fancy new Intersection Observer API.

const images = Array.from(document.querySelectorAll('img[data-src]'));

if (images.length) {
  if ('IntersectionObserver' in window) {
    setupIntersectionObserver(images);
  } else {
    loadImages(images);
  }
}

The code above is going to fetch all img elements with a data-src attribute. We know that these images are the ones that are going to be lazy loaded. The Array.from() method is an ES6 way to convert a NodeList (returned from querySelectorAll) into an Array.

Once we have an array of images to work with, and we’ve checked they exist, we then check to see whether the IntersectionObserver exists in the global window scope. This will let us know whether the visitor’s browser supports the API. If it does, then we’ll proceed to setup an intersection observer. If the browser doesn’t have support we’re going to gracefully degrade by firing a function to load all images.

Let’s go down our desired route of setting up an intersection observer first (as it’s more fun).

function setupIntersectionObserver(images) {
  const options = {
    root: null,
    rootMargin: '0px',
    threshold: 0.5
  }

  const observer = new IntersectionObserver(onIntersection, options);
  images.forEach(image => observer.observe(image));
}

Our setup function here will instantiate a new IntersectionObserver, passing an onIntersection callback function, and an options object.

Our options object doesn’t need to define the root and rootMargin options, as they default to null and 0px respectively. I’ve included them here for visibility and completeness of the intersection observer constructor.

The last thing we do in our setup function is to loop over all our images and set them as targets for our observer. Now whenever one of our lazy images become 50% visible in the viewport the onIntersection callback function will fire.

function onIntersection(entries, observer) {
  entries.forEach((entry) => {
    if (entry.intersectionRatio >= 0.5) {
      observer.unobserve(entry.target);
      loadImage(entry.target);
    }
  });
}

The callback function above has two arguments that the Intersection Observer API passes to it; a list of target entry objects, and the observer. We’re going to do 4 things in this callback function;

  1. Loop over all of the target entries. Each entry is an IntersectionObserverEntry object that contains about half a dozen properties.
  2. One entry property we’re interested in is entry.intersectionRatio. This property returns the ratio of how visible the element is in the viewport, between 0-1. In our observer we set the threshold as 0.5, and because this callback function has fired we know that at least one image has reached that mark. So we will do a conditional statement to check if the entry within the loop has met that ratio criteria of 0.5.
  3. If the entry has passed the 50% mark, then we are going to load the image by calling a loadImage function (we’ll look at this in a minute).
  4. When an image has become visible, we can use the observer argument to run observer.unobserve(entry.target). What this method does is tells the observer to stop observing the specified target element. In our case it’s the entry.target, which is an image element.

Let’s take a look at our loadImage function. All we want it to do is swap the data-src attribute for a src attribute.

function loadImage(image) {
  image.setAttribute('src', image.getAttribute('data-src'));
  image.onload = () => image.removeAttribute('data-src');
}

Pretty straight forward. By setting the src attribute it will cause the image to load, and then once the image has loaded, we remove the data-src attribute that’s no longer needed.

There’s one function that we have left to write, and it’s our graceful degrading loadImages function that’s going to run if the website visitor is using an older, non-compatible, browser.

function loadImages(images) {
  images.forEach(loadImage);
}

Talk about reusability! We already wrote a function to load an individual image, so all we’re doing here is looping all lazy images and invoking the loadImage function to load the image. This code will load all lazy images on the page at once, but for a simple fallback I find it suffice.

At this point we now have a working lazy loading script, but the user experience is a bit clunky. The images just jump onto the page, and lazy images look undesirable when there’s no src attribute.

CSS (SCSS) Styling

img {
  opacity: 1;
  transition: opacity 0.5s ease-out;
  
  &[data-src] {
    opacity: 0;
  }
}

Here we’ve set a basic opacity transition of 500ms, so when the data-src attribute is removed (when the image has loaded) the loaded image fades in nicely. This feels much smoother than before, and we could extend this to do all kinds of crazy stuff like scale, translate, flip etc.

Conclusion

The Intersection Observer API is an amazing new web technology, and massively simplifies tasks such as lazy loading of images that were problematic in the past. With just a few lines of code you can reduce the initial payload of a site drastically by deferring off screen images with this lazy loading technique. I’ve used this technique on huge enterprise-level websites and have seen incredible performance gains from it, so it’s definitely worth a try!