Why lazy load

According to HTTPArchive, images are the most requested asset type on websites and they usually take up more bandwidth than any other resource. At the 90th percentile, sites send about 4.7 MB of images to the user. That's a lot of cat pictures!

Lazy loading means only loading assets when a user needs them. This keeps the experience fast and prevents unnecessary data usage. Additionally, if there’s less data over the wire, there’s less user device usage. And the servers that host the app are hit less frequently. Lower power consumption means a lower carbon footprint. Lazy loading is basically a win all around and this article will tell how to do it properly.

Lazy loading is becoming increasingly important as websites get richer in content. Modern pages feature many images and video. Globally, the internet speeds have not increased as much as the explosion of media heavy pages.

So, how does lazy loading work? The simplest definition is this one: “Do not load things that are not visible on the screen”. For example only load a big mega menu with images inside of it when the user opens the menu. Also consider lazy loading the comment section on a blogpost or a google maps embed at the bottom of a page.

Things that can be lazy loaded

Images

Images are the most famous thing to lazy load around the web. This wasn’t an easy thing to do back in the day. The code would watch the scroll and when the user came close to an image we would dynamically add the image URL into the src attribute. This meant that the browser would only know about the image, its ratio and its dimensions, at the moment of loading. With this approach there is a big potential of layout shift on load of an image. This was mitigated by creating placeholders with the exact dimensions of the image. But, how would you do that if the image was user contributed? There is no way of knowing what kind of aspect ratio is uploaded. Workaround after workaround was created to get things working properly. Over time there was a chance to modernize. Developers stopped listening to the scroll event and started using IntersectionObserver and object-fit .

HTML:

<img class="lazy" data-src="https://timbenniks.dev/tim.jpg" alt="This is Tim" width="100" height="200" />

CSS:

img {
  width: 100%;
  height: auto;
  display: block;
}

img.lazy {
  opacity: 0;
}

img.lazy-loaded {
  opacity: 1;
}

img::before {
  content: '';
  display: block;
  padding-top: 56.25%; /* ratio 16:9 */
}

JS:

function findImages() {
  const images = [...document.querySelectorAll('img.lazy:not(.lazy-loaded)')]
  
  images.forEach((image) => {
    const observer = new IntersectionObserver((changes) => {
      changes.forEach((change) => {
        if (change.isIntersecting) {
          if (!image.getAttribute('lazy-loaded')) {
          	image.src = image.getAttribute('data-src')
            image.classList.add('lazy-loaded')
          }
          observer.unobserve(image)
        }
      })
    })
    observer.observe(image)
  })
}

There are various ways to prevent content reflow or “Cumulative Layout Shift”. If you want to know about the history of how this was done, read the following: https://css-tricks.com/preventing-content-reflow-from-lazy-loaded-images/

Today things are different

Nowadays there is native lazy loading in almost all major browsers. Only IE 11 and (of course) Safari are not compliant. However, as native lazy loading depends on an HTML property, if the browser does not know about it, it will just gracefully ignore it and the image will load normally. This is progressive enhancement at its finest.

Without adding the loading attribute, browsers already load images at different priorities depending on where they're located with respect to the device viewport. Images below the viewport are loaded with a lower priority, but they're still fetched as soon as possible. But, with the loading attribute you can completely defer the loading of offscreen images. More control makes developers happy.

To make lazy loading work there are two things that need to be added:

  1. Add `loading="lazy"` or `loading="eager"`.
  2. Add a width and a height. Even if images are scaled with CSS. Now the browser knows the ratio of the image and it will make sure there is no content reflow.

These are the lazy properties and what they do:

  • lazy - Deferring the loading of assets until it reaches a certain distance from the viewport.
  • eager - loading the assets as soon as the page loads, irrespective of where they are placed on the page, whether above or below the page fold.

The HTML for an image is simple:

<img src="cat.jpg" loading="lazy" alt="a cat" width="200" height="200"/>

And a picture tag:

<picture>
  <source media="(min-width: 800px)" srcset="fatter-cat.jpg 1x, fattest-cat.jpg 2x">
  <img src="cat.jpg" loading="lazy">
</picture>

Some considerations

  1. Native lazy loading doesn’t work on CSS background images yet. The chrome team is working on this at the time of writing.
  2. Native browser level lazy loading is focused on providing best visual experience and not on saving data over the wire. This means that the scroll threshold for when it starts loading an image is pretty big. Luckily they do have a smart approach. On faster connections the threshold is smaller. On 3G the threshold is 2500px and on 4G it is 1250px. This means that the browser will look 1250px below the current scroll position and will start loading images within that threshold. When the user scrolls down the images are already loaded and the visual experience is optimal.
  3. If you want to save data rather than focus on the visual experience, look at the approach described above where the developer can control exactly when images load based on the `IntersectionObserver`.

One last thing: it is safer to avoid putting loading=lazy on above-the-fold images, as browsers won't preload loading=lazy images in the preload scanner. Either add no loading attribute or set it to loading=eager.

IFrames

Native lazy loading now also works on iFrames. However, the loading attribute affects iFrames differently than images, depending on whether the iframe is hidden. Hidden iFrames are often used for analytics or communication purposes. This is how a browser determines if an iframe is hidden:

  • The iFrame’s width and height are 4px or smaller.
  • display: none or visibility: hidden is applied.
  • The iFrame is placed off-screen using negative X or Y positioning.

Lazy loading all iFrames is important as they are a black hole of data and resource fetching. Especially things like YouTube or Vimeo embeds greatly benefit page performance when lazy loaded.

A quick code example:

<style>
  .video-wrapper {
    position: relative;
    padding-bottom: 56.25%; /* 16:9 aspect ratio */
    width: 800px;
  }

  iframe {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
  }
</style>

<div class="video-wrapper">
  <iframe
    width="16"
    height="9"
    allowfullscreen
    frameborder="0"
    loading="lazy"
    src="https://www.youtube.com/embed/JTGHl7ggOAs?rel=0">
  </iframe>
</div>

Video

Based on what is described in this article it is conceivable that lazy loading videos gives an equal gain in performance. This is not true. Video codecs have a lot of smartness in them that tell the browser and server what to do when playing a video. Lazy loading a video can sometimes deregulate this behaviour and odd things can happen. A codec can dictate for example: pre-load one second, and after that, stream the video. Or: preload the whole video before playing. Doing custom lazy loading results in strange behaviour. If you have larger videos to show consider MPEG-DASH or HLS streaming. Cloudinary has a great service for this.

Third party JS libraries

Lazy loading third party libraries or banner ads is the most complex thing to do but very rewarding. On bigger websites a huge portion of downloaded resources are actually third party scripts that slow the website down.

Lazy loading can be used to have absolute control over when third party libraries do their thing. However, there is no native lazy loading in place for this as all third party libraries act a little differently. Below is a possible way of lazy loading third party scripts.

Before implementing lazy loading for a third party, check if the GDPR regulations are set up correctly. Some third parties (like cookie banner scripts) need to be loaded before other things happen on the page.

A generic function to lazy load and keep state of third party scripts.

// injectscript.js
window.$INJECTED_URLS = {}
export default {
  hasScript(url) {
    return window.$INJECTED_URLS[url]
  },

  inject(url, options = {}) {
    if (!this.hasScript(url)) {
      const tag = document.createElement("script")
      const head = document.getElementsByTagName("head")[0]
      
      tag.src = url;
      tag.async = options.async !== undefined ? options.async : true
      tag.type = "application/javascript"

      Object.keys(options)
        .filter((key) => key !== "async")
        .forEach((key) => {
          if (options[key]) {
            tag.setAttribute(key, options[key])
          }
        })

      window.$INJECTED_URLS[url] = new Promise((resolve) => {
        tag.addEventListener("load", resolve)
        head.appendChild(tag)
      })

      return window.$INJECTED_URLS[url];
    }

    return Promise.resolve().then(() => window.$INJECTED_URLS[url]);
  },
}

How to use it:

import InjectScript from "./injectscript"

const src = "<src_of_3rd_party_script>"

if (!InjectScript.hasScript(src)) {
  InjectScript.inject(src, {
    crossOrigin: true,
	  async: true,  
    // whatever options you want to add to the script tag
  }).then(() => {
      // Script has loaded.
      // Fire initialization function of 3rd party library.
    }
  
  else if (InjectScript.hasScript(this.src)) {
    // Script was already loaded.
    // Fire initialization function of 3rd party library.
  }
}

Of course there is also the async and defer attributes on the script tag.

If the third party library is built smartly, they would have considered these attributes.

Defer

The defer attribute tells the browser not to wait for the script. Instead, the browser will continue to build the DOM. The script loads in the background, and then runs when the DOM is fully built. Scripts with defer never block the page. Scripts with defer always execute when the DOM is ready but before the DOMContentLoaded event.

Async

The async attribute is somewhat like defer as it also makes the script non-blocking. But it has important differences in behaviour. Async scripts load in the background and run when ready. The DOM and other scripts don’t wait for them, and they don’t wait for anything. A fully independent script that runs when loaded.

Concluding

Lazy loading is not that hard anymore and you should be doing it. Modern browsers are marvels of engineering and you should be using their powers for good. Keep in mind that a lot of new people are starting to get smartphones but their internet connections aren’t that fast just yet. Lazy loading will help them experience websites better and it will be better for the environment as less data goes over the wire.

See the code here: https://codesandbox.io/s/musing-fermi-cy7q2