How to lazy load and initialize elements using an Intersection Observer

Save bandwidth in a CPU-efficient way by loading things only when they are visible

Author's image
Tamás Sallai
10 mins

Lazy initialization

One upside of using low-end hardware is that I'm the first to notice when something is slow and I'm kinda forced to do something about it. Usually, companies follow Joel's advice and give programmers the best tools money can buy, in which case there is a separation from end-users. On a high-end smartphone using WiFi with a fiber connection, nothing is slow, but your average visitor might not have that.

I have a site with a few Leaflet maps here and there and I realized that initializing even a few maps on page load makes a noticeable bump when I open the page. One map is not a problem, but five is. So I started looking for solutions for how to reduce the amount of work done on page load.

The obvious approach is lazy loading. Why initialize an element on the bottom of the page when the majority of users don't even scroll down that far? By implementing lazy loading, the site not only will load faster, but it will also consume less bandwidth overall.

In terms of code, initializing a Leaflet map looks like this:

const mapElement = document.querySelector("#map");
const mymap = L.map(mapElement).setView([51.505, -0.09], 13);
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const osmAttrib = "Map data &copy; <a href=\"https://openstreetmap.org\">OpenStreetMap</a> contributors";
const osm = new L.TileLayer(osmUrl, {maxZoom: 18, attribution: osmAttrib});
osm.addTo(mymap);

I wanted to run this code only when the element is visible and not before.

After a bit of searching, I've found the almost ideal solution specifically engineered to solve this problem: the Intersection Observer. Its name sounds like something only available on Chrome behind a vendor prefix, but in reality, it has wide support. While it seems like it won't change too much, at the time of writing it was in "Working Draft" status, which means it can change significantly.

Intersection Observer basics

Without the Intersection Observer, detecting when an element becomes visible can be done by listening to scroll and resize events and calculating whether the element's bounding rectangle intersects with the viewport, while also taking into account any scrollable elements and iframes.

This approach, while it works, is wrong on two accounts. First, it is extremely ineffective as running a bunch of getBoundingClientRect()s for every scroll event, that can come multiple times a second, consumes a lot of CPU. And second, it's quite hard to do it right, considering all the edge cases of scrolling and clipping elements.

Meet the Intersection Observer, which abstracts all that away. You just define which element you want to check, and it will invoke a callback when something interesting happened. No more excessive event checking, and it takes care of all the elements between the target and the viewport.

To create and use an Intersection Observer, create a new object with a callback function:

const observer = new IntersectionObserver((entries) => {
	// something interesting happened
});

When the callback is run, it does not mean that the element is visible. That's why you need to check the isIntersecting property:

const observer = new IntersectionObserver((entries) => {
	if (entries.some(({isIntersecting}) => isIntersecting)) {
		// intersection happened!
	}
});

Now that the Observer is in place, start observing an element by calling observe:

observer.observe(element);

To stop it, use disconnect:

observer.disconnect();

These are the basics, and they are everything required to make a lazy initializer function that notifies a callback when an element becomes visible:

const lazyInit = (element, fn) => {
  const observer = new IntersectionObserver((entries) => {
    if (entries.some(({isIntersecting}) => isIntersecting)) {
      observer.disconnect();
      fn();
    }
  });
  observer.observe(element);
};

For my use case with Leaflet, enabling lazy loading requires just two lines of extra code:

const mapElement = document.querySelector("#map");
lazyInit(mapElement, () => {
	const mymap = L.map(mapElement).setView([51.505, -0.09], 13);
	const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
	const osmAttrib = "Map data &copy; <a href=\"https://openstreetmap.org\">OpenStreetMap</a> contributors";
	const osm = new L.TileLayer(osmUrl, {maxZoom: 18, attribution: osmAttrib});
	osm.addTo(mymap);
});

Element visible on page load

An edge case is when the element is visible immediately on page load. Fortunately, Intersection Observer handles this case.

The above script runs just fine, no matter when the element is located.

Element sizing

An important aspect of lazy loading is element sizing. For example, when you load an image only specifying the width, its height is determined by the aspect ratio of the image itself. If you load the image lazily, this might mean a different height before and after loading which in turn moves everything below it.

It usually does not manifest as a problem during development and testing. If the image takes a split second to load, or at least to start loading, it has its final height by the time you scroll through it.

But the situation for a new visitor might be different. Imagine a low-end device with an Internet connection having > 1-second pings, and empty browser and DNS caches. Having elements changing their sizes is a UX nightmare, as you are trying to read something then suddenly it goes up a bit, then down again. I experience it with ads especially on mobile, and it's just terrible.

When you implement a lazy-loading solution, make sure to set the dimensions for the placeholder right.

Lazy loading images

Apart from Leaflet, another great candidate for lazy loading is images. They tend to be one of the biggest contributors to page size, and they tend to be scattered all over the page. Not loading the bottom ones during page load can save a lot of bandwidth.

But remember to include something in the HTML with the right dimensions on page load to make sure content below the image does not jump around. This can be as simple as a single transparent pixel, or as fancy as a downsized version of the original image. For the former, here is a base64-encoded version:

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" ...>

Let's construct a reusable solution that works for any img tags!

To store the URL of the image, a data- attribute is a good solution. Then when the lazy loading script kicks in, it just replaces the src with the data- property. For the examples, I'll use a data-lazyload="<url>" construct.

To get all images for lazy loading, use: document.querySelectorAll("img[data-lazyload]"). This returns a list with all the imgs that have the data-lazyload property.

Then it's just a matter of calling the lazyInit function and setting the src attribute:

document.querySelectorAll("img[data-lazyload]").forEach((img) => {
	lazyInit(img, () => {
		img.src = img.dataset.lazyload;
	});
});

Don't forget to set the width/height of the element either via width="..." height="..." attributes or CSS.

Add margins

The lazyInit implementation fires off the callback when the target element becomes visible. This means the user is likely to notice the loading process and, especially on slower connections, might need to wait a bit. It would be better to start loading a little bit ahead of time so that by the time the viewport reaches the element it is fully loaded.

Of course, it becomes a UX-bandwidth tradeoff. If elements are loaded too soon then it wastes bandwidth, if they are loaded too late it degrades UX.

Intersection Observer supports a solution for this. You can specify a margin that is applied to the element when calculating intersections. For example, you can specify a margin of 100px and that will fire the callback when the target is less than 100 pixels from the viewport. This makes HTML+CSS hacks like invisible absolute-positioned elements around the target unnecessary.

To set this margin, use the second argument of the constructor:

const observer = new IntersectionObserver((entries) => {
	// ...
}, {rootMargin: "100px"});

It accepts the same values as the margin CSS property, so "100px" is 100 pixels in all directions, while "100px 0 0 0" adds 100 pixels only to the top.

Problems in IFrames

The problem with rootMargin is that it does not play well with IFrames. Unlike the basic functionality of the Intersection Observer, it does not work the same when a page is embedded in another one.

Take a look at this page, hosted on GistRun that is using IFrames to show a GitHub gist in action. As you scroll down the page, you'll see when the Intersection Observer starts loading the image and initializing the map. You'll see that there is no margin.

The same gist, but without an IFrame is available here. The same logging, and you'll see that now the callback fires well before the target element is in view.

Seems like there are inconsistencies between the browsers and whether the margins are negative or positive. Some tickets are tracking this behavior and some progress on fixing this. But unfortunately, the fix is mainly concerned about allowing the IFrame's document to be a valid root (by default it's the viewport of the browser), which still means it will work differently depending on whether the page is loaded in an IFrame or not.

Fortunately, the Intersection Observer still works, it just ignores the rootMargin. For lazy loading, that means a slightly degraded user experience, but the elements will be initialized nevertheless.

But keep this issue in mind as it might break functionality for a use-case other than lazy loading.

Other configs

There are some other config properties for the Intersection Observer which are not needed for lazy loading but no article on this subject is complete without at least mentioning them.

root

The root specifies the element thats intersection with the target we are interested in. If unspecified it is the viewport, which is exactly what is needed for lazy loading.

The spec defines how to take scrolling and clipping elements between the root and the target into account, so it just automagically works the way you expect.

threshold

The threshold is the required percentage of intersection for the callback to be called. The default is 0, which means any part of the element comes into view, the callback will be fired.

To require half of the element to be intersected, use:

const observer = new IntersectionObserver((entries) => {
	// ...
}, {threshold: 0.5});

Another sytactic sugar is that you can use an array to report on multiple stages: threshold: [0.25, 0.5, 0.75].

The MDN article has an awesome example that helps with visualizing how the threshold works. Notice that scrolling the IFrame or scrolling the page does not make a difference, the intersections are correctly reported.

Don't forget that the value of 1 means the callback is only run when the entire element is visible. If the element is bigger than the viewport (the root), it might never happen.

Conclusion

The Intersection Observer replaces the complicated and resource-intensive scroll and resize events for lazy loading. Apart from its handling of the additional margin inside IFrames, it abstracts away the edge cases and provides a terse API. While it's not a full replacement to other events, whenever it's power is enough, it should be the preferred solution.

February 4, 2020