Cobwwweb logo

Lazy Load Images Using Intersection Observer API

Jan 07, 2019 JavaScript

Our goal here is simple. We want to load images only when we can see them within the viewport.

It used to be that figuring out whether an element was present within the viewport was a heavy and imprecise operation. For example, you could look at every element you care about every time a scroll event is fired and look for its position on the screen relative to the top left corner and compare that to the current scroll position. Yes, it was a nightmare.

Fortunately, today JavaScript comes packed with a powerful feature in its Intersection Observer API that makes figuring out which elements are within the viewport nice and simple.

Before we get to this API, let's look at the structure of the markup I'm going to use in the example. After that, we will add the necessary JavaScript and to achieve lazy loading.

Image Grid

With the help of Unsplash's embedding feature, I'll use a grid of images to demonstrate the lazy loading process:

<div class="container">
  <img src="">
  <img src="">
  <img src="">
  <img src="">
  <!-- And so on ... -->

Note: The dimensions change slightly from image to image so Unsplash delivers a different image for each <img> element.

With a little CSS, the images can be displayed in a four-column grid using CSS Grid Layout:

:root {
  font-size: 16px;

.container {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr;
  grid-gap: 1rem;
  padding: 1rem;

img {
  width: 100%;

This is the result:

This is not an ideal way to load images on a page because we're loading all the images when the page loads, including some that users may never see unless they scroll. This slows down the load time for the page, ultimately resulting in a longer duration before users can interact with the page's content.

So, for the next step, we're going to introduce the idea of a placeholder image.

Placeholder Image

With a placeholder image, the source of the image is a small and consistent (shared) image, such that there is only a single placeholder image shared among all (to-be lazy loaded) elements on the page. But, we don't want to lose the reference to what we want the src attribute ultimate will be, so we store that value as a data attribute (data-src) on the image, like so:

<img src="" data-src="">
<img src="" data-src="">
<img src="" data-src="">
<!--- And so on ... -->

Now when the page loads, only the single placeholder image is loaded, so content can be rendered and interacted with sooner. In the next section we'll use JavaScript to set each image's src attribute to its data-src attribute (showing the image we want) after page loads and when the image intersects the user's viewport.

Note that the key to the placeholder trick is being able to set the image width to 100% or some other knowable width. That way we can have a really small image (10px wide in this example) stretched to the appropriate size that loads quickly.

This is what we get with only placeholder images (without the necessary JavaScript):

Lazy Loader

Last, it's time for the JavaScript. Before we do that, there's one more piece to the markup. We're not going to assume every image should be lazy loaded. Instead, we'll have to explicitly ask for lazy loading to affect an image by adding a data-lazy-load attribute to each image we want lazy loaded.

<img src="" data-src="" data-lazy-load>
<img src="" data-src="" data-lazy-load>
<img src="" data-src="" data-lazy-load>
<!--- And so on ... -->

Now we can write the JavaScript. Here it is, commented to help you understand what's going on:

(function() {
  // Initialize Intersection Observer. The argument passed here is the callback
  // function that should be run when the observer is triggered.
  var observer = new IntersectionObserver(onIntersect);
  // Observe every element with the "data-lazy-load" attribute for it to
  // intersect the screen.
  document.querySelectorAll('[data-lazy-load]').forEach(function(img) {

  // This is the callback function when the observer is triggered. entries is an
  // array of all observable elements for which the function was triggered, and
  // observer is our observer instance.
  function onIntersect(entries, observer) {
    // Step through each entry in the entries array ...
    entries.forEach(function(entry) {
      // Don't do anything if the element has already been processed or if it
      // isn't currently intersecting. The Intersection Observer also fires when
      // an element leaves the viewport, which is why we need this check.
      if ('data-processed') || !entry.isIntersecting) return true;
      // Set the images source to the value of the "data-source" attribute. This
      // is why we were storing the source we ultimately want to load in a data
      // attribute.'src','data-src'));
      // Add a new attribute to the image called "data-processed" and set it to
      // true. We do this so we only process each element a single time and we
      // don't try to reload an image that's already been loaded.'data-processed', true);

Hopefully the comments are enough to follow the code. The one thing missing is that that the code is wrapped in an anonymous function ((function() {})()) that gets run automatically when the script is loaded. This is a common method for keeping JavaScript code local so the variables and functions don't bleed out into other JS code used throughout the site.

Note that the Intersection Observer API is not supported by Internet Explorer. If you need IE support, you'll want to load the polyfill prior to your code.

When we put it all together, this is what we get:

(If the lazy loading didn't work as you scrolled down the page, you can visit the demo directly.)

Ways To Improve

That's all it takes to get started, but as you can see, it's not super polished. Here are a few ideas on where to go from here:

Did you learn something or find this article interesting?

If so, why not