Lazy loading images with vanilla JavaScript
Tags: webperf
Published: 2019-06-23 14:32:06

*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](https://thepracticaldev.s3.amazonaws.com/i/rllvlqv7mgxzoeij83hx.jpg)
<figcaption>Image from the film “Spirited Away” (2001)</figcaption>

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](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API), which is already supported by [most modern browsers](https://caniuse.com/#feat=intersectionobserver), 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.

{% glitch lazy-cats app %}

_**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.

```html
<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.

```html
<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.

```html
<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.

```html
<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.

```js
// 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.

```js
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.

```js
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](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry)—for our purpose, we use `.isIntersecting` to replace our placeholder image with the actual image source _only_ when each image enters the viewport.

```js
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.

```html
<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](https://thepracticaldev.s3.amazonaws.com/i/6328rllrmxtzifw40psx.png)

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](https://thepracticaldev.s3.amazonaws.com/i/688zncwb66234rca9gj3.png)

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](https://thepracticaldev.s3.amazonaws.com/i/cq87b3heox0rit3zv6ls.png)

### Audits (Lighthouse)

![Lighthouse Audit results of page without lazy load](https://thepracticaldev.s3.amazonaws.com/i/5j6dfl184n5k7dmpij6d.png)

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](https://thepracticaldev.s3.amazonaws.com/i/zbjaxpz7ndszgvpimblh.png)

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

* Lazy loading libraries
	* [lazysizes](https://github.com/aFarkas/lazysizes)
	* [lazyload](https://github.com/verlok/lazyload)
	* Plugins for specific platforms:
		* [6 Lazy Load Plugins to Make Your WordPress Site Faster](https://premium.wpmudev.org/blog/6-lazy-load-plugins-to-make-your-wordpress-site-faster/) — WPMU
		* [gatsby-image](https://www.gatsbyjs.org/packages/gatsby-image) and [gatsby-remark-images](https://www.gatsbyjs.org/packages/gatsby-image)
* Lazy loading enhancements
	* [IntersectionObserver polyfill](https://github.com/w3c/IntersectionObserver/tree/master/polyfill) — W3C
	* [Lazy Loading Images and Video](https://developers.google.com/web/fundamentals/performance/lazy-loading-guidance/images-and-video) — Jeremy Wagner
	* [How to use SVG as a Placeholder, and Other Image Loading Techniques](https://jmperezperez.com/svg-placeholders/) — José M. Pérez
	* [Native image lazy-loading for the web!](https://addyosmani.com/blog/lazy-loading/) — Addy Osmani
		* 🚧 In development; only available in Chrome 75 and  has to be enabled manually under `chrome://flags`
* Other image optimization strategies
	* [Responsive images](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images) — MDN
	* [Optimize your images](https://web.dev/fast#optimize-your-images) — wev.dev
* About Intersection Observer
	* [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) — MDN
	* [Trust is Good, Observation is Better—Intersection Observer v2](https://developers.google.com/web/updates/2019/02/intersectionobserver-v2) — Thomas Steiner