DEV Community

loading...

Lazy loading images with vanilla JavaScript

Eka
Web developer, late bloomer. Can center an element with grace and style (pun intended). Mostly sensible (citation needed).
・8 min read

Improve your website’s speed and performance by “lazy loading” images with Intersection Observer API in just a few lines of vanilla JavaScript.

“Lazy loading” is a technique that loads images or videos only when they are needed—that is, when they appear on screen. If your page contains, say, 20 images with total size of 5MB, but your user only scrolls to the third image, then it would not be necessary to request and load all 20 images immediately.

Not lazy loading your images is like ordering everything on the menu without knowing how much you’ll be able to eat. It is potentially a waste of time, resources, and—referring to the former—users’ bandwidth (which equals money for users on mobile data).

Scene from animated film Spirited Away, depicting the character No Face with so much food on the table

Image from the film “Spirited Away” (2001)

This used to be a messy effort, which involved adding/removing multiple event listeners and comparing sizes and positions. But thanks to the Intersection Observer API, which is already supported by most modern browsers, we can now implement lazy loading in under 20 lines of JavaScript!

In this post, we are building an image gallery that implements lazy loading. We’re going to do this in plain HTML, CSS, and JS—no libraries or dependencies. There are plenty lazy loading libraries and plugins (which I include at the end of this post), but this is a fun way to understand how the Intersection Observer API works and how lazy loading works at the most basic level.

You can run and “remix” the code on Glitch below.

Note: Lazy loading also works with video, but here we are focusing on images. You can find further references at the end of this post.


Implementing lazy loading

In a nutshell, these are what happens when we load our gallery page:

  1. The browser parses the page’s HTML and CSS, combines them to build a render tree.
  2. The browser calculates the space (element sizes, positions) and paints (renders) pixels to the screen. During this process, the browser requests and loads images “above the fold” normally, and loads placeholder images for the remaining images.
  3. The Intersection Observer API watches the lazy-loaded images. When user scrolls/tabs toward each image, it requests the actual image and swaps the placeholder with the actual image.

We are going to use the following files:

  • index.html — HTML markup
  • script.js — the JavaScript code
  • style.css — basic CSS styles

index.html

1. Prepare the HTML markup

Let’s start with basic HTML and CSS markup. We are making a <ul> list of cats. Each <li> list item contains the cat’s name and image.

<ul class="cats">
  <li class="cat">
    <img class="cat__img" src="https://cataas.com/cat?width=300&i=1" alt="" />
    <strong class="cat__name">Bustopher Jones</strong>
  </li>
  <!-- etc -->
</ul>

1b. Prepare the <noscript> fallback

Now let’s duplicate each <img> into a <noscript> tag for images we want to lazy load. Because we want to load the first three images normally, we do this from the fifth item onwards.

<ul class="cats">
  <!-- cats #1 to 4 -->
  <!-- start lazy loading cat #5 onwards -->
  <li class="cat">
    <img class="cat__img" src="https://cataas.com/cat?width=300&i=5" alt="" />
    <noscript>
      <img class="cat__img" src="https://cataas.com/cat?width=300&i=5" alt="" />
    </noscript>
    <strong class="cat__name">Growltiger</strong>
  </li>
  <!-- etc -->
</ul>
🧐 Fold? What fold?

With the endless variations of device sizes, we don’t actually have “above the fold” the way we do with print. The idea is to load the first x images as usual (ie. immediately)—hence including them as our page’s “critical assets”—and lazy loading the rest as non-critical assets.

Use your own discretion to decide which images to load normally on your page!

2. Make the images .lazy

At this point, we already have regular images without lazy loading. Next, we are doing three things to each <img> element we want to lazy load:

  1. Add an extra class, .lazy (feel free to use any name)
  2. Replace the src attribute value with placeholder image
  3. Add a data-src attribute with the image source value (ie. the cat image)

The placeholder image can be anything as long as it’s reasonably small. Here I use a regular 1x1px grey PNG image.

<li class="cat">
  <img 
    class="cat__img lazy" 
    src="https://cdn.glitch.com/3a5b333c-942b-4088-9930-e7ea1e516118%2Fplaceholder.png?v=1560442648212"
    data-src="https://cataas.com/cat?width=300&i=5"
    alt="" 
  />
  <noscript>
    <img class="cat__img" src="https://cataas.com/cat?width=300&i=5" alt="" />
  </noscript>
  <strong class="cat__name">Growltiger</strong>
</li>

💡 Tip: Add empty alt attribute so screen readers do not announce the image file name.

At the bottom of the page before the closing </body>, add the following style to hide the duplicate image for <noscript> view if JavaScript is not supported or disabled.

<noscript>
  <style>.lazy { display: none; }</style>
</noscript>

We are done with our HTML markup. Make sure we call our JS file in our <head> element, for instance <script src="/script.js" defer></script>, and go to the next step.

script.js

3. Prepare the function that will run Intersection Observer

The first step in our JS file is to prepare the function that will run Intersection Observer.

// Run after the HTML document has finished loading
document.addEventListener("DOMContentLoaded", function() {
  // Get our lazy-loaded images
  var lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));

  // Do this only if IntersectionObserver is supported
  if ("IntersectionObserver" in window) {
    // ... write the code here
  }
});

What happens here:

  • We add an event listener that detects when the HTML document has finished loading and parsing.
  • Then we define our images to lazy load—that is, img elements with the class .lazy.
  • To prevent error, make a conditional to run our function only if the browser supports Intersection Observer.

4. Create an Intersection Observer

Intersection Observer is a JavaScript web API that, as the name suggests, observes (watches for) when a target element intersects with (passes across) a root/container element.

An Intersection Observer consists of the following:

  • target element — in this case, our images
  • root/container element — defaults to viewport (we may optionally define any parent element of target, which we don’t need here)
  • callback function that is run when the target intersects with root — in this case, we swap src value with actual image from data-src

The API to create a new Intersection Observer object is new IntersectionObserver(callback, options).

  • callback is a function that takes two arguments, entries and observer
  • options are optional object containing options

Now we create a new observer object called lazyImageObserver and pass it a callback function. We are not using custom options here.

if ("IntersectionObserver" in window) {
  // Create new observer object
  let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
    // ... callback function content here
  });
}

5. Observe target elements

Next, we call the .observe() method on the lazyImageObserver we just created to watch the target elements, namely each of our .lazy images.

if ("IntersectionObserver" in window) {
  // Create new observer object
  let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
    // ... callback function content here
  });

  // Loop through and observe each image
  lazyImages.forEach(function(lazyImage) {
    lazyImageObserver.observe(lazyImage);
  });
}

6. Replace placeholder with the actual image source

Now we’re going to write the callback function that we left blank in step 4. This function is invoked when the target (each of our .lazy images) passes the root element (ie. enters the viewport).

The callback receives an array of IntersectionObserverEntry objects. Each IntersectionObserverEntry object has several properties—for our purpose, we use .isIntersecting to replace our placeholder image with the actual image source only when each image enters the viewport.

if ("IntersectionObserver" in window) {
  // Create new observer object
  let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
      // Loop through IntersectionObserverEntry objects
      entries.forEach(function(entry) {
        // Do these if the target intersects with the root
        if (entry.isIntersecting) {
          let lazyImage = entry.target;
          lazyImage.src = lazyImage.dataset.src;
          lazyImage.classList.remove("lazy");
          lazyImageObserver.unobserve(lazyImage);
        }
      });
  });

  // Loop through and observe each image
  lazyImages.forEach(function(lazyImage) {
    lazyImageObserver.observe(lazyImage);
  });
}

What happens when each image enters the viewport:

  • Replace src value (placeholder image) with the data-src value (cat image)
  • Remove the .lazy class
  • Mission accomplished, stop observing this image

7. Bonus: Opera Mini “Extreme Mode” fallback

The mobile browser Opera Mini has a browsing mode called “Extreme Mode”, which uses a “proxy” server to compress and convert all data to Opera’s own format called OBML, then send it to the browser.

This mode does support JS to some extent so <noscript> does not apply. However, our external JS file does not work here, so I use a basic fallback here to replace the placeholder images with the actual image. If a considerable percentage of your users use Opera Mini, do look into this.

<script>
  (function() {
    "use strict";
    if ("IntersectionObserver" in window) {
    } else {
      // `document.querySelectorAll` does not work in Opera Mini
      var lazyImages = document.getElementsByClassName("lazy");
      // https://stackoverflow.com/questions/3871547/js-iterating-over-result-of-getelementsbyclassname-using-array-foreach
      [].forEach.call(lazyImages, function (lazyImage) {
        lazyImage.src = lazyImage.dataset.src;
        lazyImage.classList.remove("lazy");
        lazyImage.height = 'auto';
      });
    }
  })();
</script>

8. That’s it!

Unless your images are exceptionally heavy and/or your user’s internet connection is too slow, users might not even notice the difference. 🤷🏽‍♀️ That said, they would most likely find your website faster and cost them less.


Results

We’re going to check the difference between the page with lazy loaded images and regular images using Chrome DevTools (similar tools are available in Firefox and Safari if you prefer), specifically the Network and Audits panels.

Network

Network results of page without lazy load

Without lazy loading:

  • 15 requests
  • 284 KB transferred
  • Finish (Load): 2.56 s
  • HTML file size: 3.2 KB

Network results of page with lazy load

With lazy loading:

  • 7 requests ➡️ 8 requests fewer
  • 172 KB transferred ➡️ 112 KB smaller
  • Finish (Load): 1.92 s ➡️ 0.64 s faster
  • HTML file size: 6.6 KB ➡️ 3.4 KB larger (due to more markup)

💡 Bear in mind that Load time is affected by various factors, not just the image content.

The browser makes subsequent requests for the remaining images as we scroll down.

Network results of page with lazy load with more images loaded as user scrolls down

Audits (Lighthouse)

Lighthouse Audit results of page without lazy load

Without lazy loading:

  • Performance: 96
  • Time to Interactive: 2.1 s
  • First Meaningful Paint: 1.5 s

Lighthouse Audit results of page with lazy load

With lazy loading:

  • Performance: 100
  • Time to Interactive: 1.9 s
  • First Meaningful Paint: 0.6 s

In conclusion, lazy loading images with the Intersection Observer API generally improves your page speed and performance, yet it makes your HTML code slightly larger. This post aims to show the most basic implementation; be sure to check out the next section for further ideas and references.

Thanks for reading!


References

Discussion (6)

Collapse
spock123 profile image
Lars Rye Jeppesen

Great stuff but native lazy looading is right around the corner.....

Collapse
stereobooster profile image
stereobooster
Collapse
spock123 profile image
Lars Rye Jeppesen

It's behind a flag in Chrome75.. hope it will go public in 76 or 77.. works great.

Until then using InsersectionObserver as shown here is the way to go.

Once it's natively in Chrome and Firefox it's good enough /s

Collapse
goudekettingrm profile image
Robin Goudeketting

Really plug and play. Create guide, thank you(:

Collapse
sohammondal profile image
Soham Mondal

I have a question - say, the functionality of a button click relies on an external JS. But until the JS loads fully, the click will fail. How to handle this situation? Is there a way to disable the button until the JS loads?

Collapse
jbradford77 profile image
Jennifer Bradford

This is brilliant! Thank you! Even if the next version of chrome has lazyload as a default, a big chunk of traffic (on the site I work on) comes from safari and firefox.