This article is part 2 of Fly's Google Lighthouse Series, which is a series aimed at helping you achieve better lighthouse scores through solutions to each Lighthouse audit recommendation. The series starts here, enjoy!

We'll be continuing our Google Lighthouse series with part two, where I'll be explaining how you can improve your Lighthouse performance score by lazy-loading offscreen and hidden images. Feel free to check out the first article in this series, Google Lighthouse series part one: improve scores with next-gen image optimization, for an introduction to Google Lighthouse, how to run an audit and how to improve your performance score with next-gen image optimization.

If you're all caught up, strapped in and ready to go, let's begin!

What is lazy-loading and how can it improve web performance?

If you're one of the many web performance gurus out there today, you're probably aware of the impact that offscreen images can have on your site. If not, allow me to explain. Offscreen images are images that appear below-the-fold (the portion of the page that requires scrolling in order to see content). If you run a Lighthouse audit against your site, you may see the performance recommendation "Offscreen images", with the advice to "consider lazy-loading offscreen and hidden images to improve page load speed and time to interactive".

Lazy loading images means loading images on your website after the above-the-fold content is fully loaded (the portion of the page thats visible when the page first loads). It also means only loading images when they appear in the browser’s viewport, rather than during the initial page load. In other words, the images occurring below-the-fold will not be loaded until the user scrolls to them.

Since users can't see offscreen images when they first fire up your page, there's no reason to have them download these images as part of the initial page load. In essence, deferring the load of offscreen images can dramatically speed up page load time and allow users to interact with your site faster ... creating a seamless experience for them and a better conversion opportunity for you.

Lazy-loading in the real world

Google's recent announcement about using page speed as a mobile search ranking factor means there has never been a better time to improve your site speed. And honestly, this could make all the difference in the world between users staying or fleeing your site.

Take unsplash.com for example, one of the largest online sites for free, high resolution photos. They have thousands and thousands of image results for any given search. Can you imagine how long it would take to load thousands of images on the initial page load? ZzZzZzZzZzZz ... oh you're still there? Sorry I was taking a nap. Anyway, you get my point.

Unsplash optimizes their site for performance by only loading images that are in the user's current view. Smart, right? So, as the user scrolls down the page, the new images that come into view will quickly load as the user scrolls to them, saving a plethora of loading time and setting them up for an overall better performance rating.

If this concept is still a little fuzzy to you, head over to unsplash.com and take a look for yourself. As you scroll through their images, you'll notice very split seconds of gray boxes in the place of images. The images quickly replace the gray boxes as you scroll to them. Now, open up your developer tools and click on the Network tab. Here, you'll see the images actually fulfilling their "get" requests in real time as you scroll. This is the pinnacle of lazy-loading.

You can apply this same logic to the images on your site by using Fly's simple "lazy-images" library. This library allows us to only download above-the-fold images during the initial request by replacing below-the-fold images with lazy loading images. Ultimately, the below-the-fold images are only downloaded if the user scrolls to them.

import html from './html'

export default function lazyImages(selector, skip) {
  if (!skip) skip = 0
  if (typeof selector === "string") {
    selector = [selector]
  }
  if (!selector || !(selector instanceof Array)) {
    throw new Error("lazyImages selector must be a string or array")
  }
  const fn = async function lazyImages(req, next) {
    let resp = await next()

    const doc = await html.responseDocument(resp)
    if (!doc) return resp // not html

    for (const s of selector) {
      const images = doc.querySelectorAll(s)

      let idx = 0
      for (const img of images) {
        if (++idx < skip) {
          continue
        }
        const src = img.getAttribute("src")
        const srcset = img.getAttribute("srcset")
        let style = img.getAttribute('style') || ""
        img.setAttribute('data-style', style)

        style = `visibility: hidden;${style}`
        img.setAttribute('style', style)
        if (src) {
          img.setAttribute("src", '')
          img.setAttribute("data-image-src", src)
        }
        if (srcset) {
          img.setAttribute("srcset", '')
          img.setAttribute("data-image-srcset", srcset)
        }
        img.setAttribute("onerror", '')
      }
    }

    if (!doc.querySelector("script#lazy-images")) {
      let script = await fetch("file://image-observer.js")
      script = await script.text()
      const target = doc.querySelector("head")
      if (target) {
        target.appendChild(`<script id="lazy-images">${script}</script>`)
      }
    }
    const body = doc.documentElement.outerHTML
    return new Response(body, resp)
  }
  return fn
}

How it works

This library searches our document for any image tags and replaces src attributes with data-image-src attributes (data attributes are useful for storing invisible information about an element). Only a specified number of images are initially downloaded for the user to see. The document is then observed for any image tags that are revealed as the user scrolls down the page. When an image tag is revealed, its data-image-src attribute is replaced with a regular src attribute and displayed, ultimately loading images as they are scrolled to.

The script starts off with import html from './html' which uses the JS file below. In a nutshell, this JS file parses our document so that it can be read for image tags. The file is used here: const doc = await html.responseDocument(resp), within our lazy-images library, to convert our document object into easily readable text that can be later used to find images and manipulate them.

// html.js 
export async function withDocument(resp) { 
  if (resp.document) { 
    // already done 
    return resp 
  } 

  const contentType = resp.headers.get("content-type") || "" 
  if (!contentType.includes("text/html")) { 
    return resp 
  } 

  let html = await resp.text() 
  // the body can't be read again, make a new response 
  resp = new Response(html, resp) 
  resp.document = Document.parse(html) 
  return resp 
} 

export async function responseDocument(resp) { 
  resp = await withDocument(resp) 
  return resp.document 
} 

export default { 
  withDocument, 
  responseDocument 
} 

Then, our lazyImages function is declared and it accepts two arguments, selector and skip. We will eventually pass image[src] into our function as our selector param. This way we can grab all image tags within our document and manipulate them.

skip will be whatever number of images you would like to load before lazy-loading images (ex: if skip=4, you are saying “lazy-load all but the first 4 images”). If you do not specify a number for skip, it will be assumed that you want to lazy-load all images and skip will default to 0.

The document is then parsed and searched for image tags. Any images that are found will have their src, srcset and style attributes replaced with data-image-src, data-image-srcset and data-style. Data attributes are used to store private information about a given element. In this case, we want to keep src and style information associated with the corresponding image, but we don’t want it readily available for the document to immediately display. This allows us to search the document again later for these new attributes and make this data available only when needed (when a user scrolls to an image).

Overall, the only images that will download on the initial page load will be whichever images we specified in our skip parameter, if any. All other images will be lazy-loaded by following the code below...

The image-observer.js script is then added to our document. This file can be found below. Don't forget to add this file to your .fly.yml so that it can be found when fetched.

# .fly.yml
files:
  - image-observer.js
// image-observer.js
(function () { 
  function preloadImage(img) { 
    const src = img.dataset['imageSrc'] 
    if (src && src.length > 0) img.src = src 

    const srcset = img.dataset['imageSrcset'] 
    if (srcset && srcset.length > 0) img.srcset = srcset 

    const style = img.dataset['style'] || "" 
    img.style = style 
  } 
  
  function hookupPreloads() { 
    const images = document.querySelectorAll('img[data-image-src],img[data-image-srcset]'); 
    // If we don't have support for intersection observer, load the images immediately 
    if (!('IntersectionObserver' in window)) { 
      Array.from(images).forEach(image => preloadImage(image)); 
    } else { 
      const config = { 
        // If the image gets within 50px in the Y axis, start the download. 
        rootMargin: '50px 0px' 
      }; 

      // The observer for the images on the page 
      let observer = new IntersectionObserver(onIntersection, config); 
      function onIntersection(entries) { 
        // Loop through the entries 
        entries.forEach(entry => { 
          // Are we in viewport? 
          if (entry.intersectionRatio > 0) { 
            // Stop watching and load the image 
            observer.unobserve(entry.target); 
            preloadImage(entry.target); 
          } 
        }); 
      } 

      images.forEach(image => { 
        if (!image.waitingForPreload) { 
          observer.observe(image); 
          image.waitingForPreload = true 
        } 
      }); 
    } 
  } 

  const i = setInterval(hookupPreloads, 200) 
  document.addEventListener("DOMContentLoaded", function () { 
    clearInterval(i) 
    hookupPreloads() 
  }) 
})() 

This script watches the document for any image tags appearing with the attributes data-image-src and data-image-srcset.

It uses the Intersection Observer API to asynchronously observe if any images with these attributes are coming into the user’s view. If an image gets within 50px of the Y axis, the image begins to download by replacing it’s data attributes with src, srcset and style attributes, making the images available to display, saving valuable loading time and equipping your site for a better Lighthouse performance score!

You can invoke this function anywhere you wish inside of your app like this:

lazyImages('img[src]', 2)

This is saying to lazy-load all but the first 2 images. So the first 2 images appearing on your site will be downloaded on the initial page load, while all other images will be downloaded only if and when the user scrolls to them.

Before lazy-loading images

offscreenPerfBefore@2x
offscreenBefore@2x

The image above reflects a site's Lighthouse score before offscreen images are lazy-loaded using this library. The performance score is weak and the amount of time and space used to download all images is unacceptable. But, as you just learned, there's no need to download every single image at once.

After lazy-loading images

offscreenPerfAfter@2x
offscreenAfter@2x-2

When you defer image loading by incorporating this library into your site, you'll probably see a refreshing performance score like this, as well as minimal time/space wasted.

Changing the web performance game, one Lighthouse score at a time

More and more we are seeing developers gaining the upper hand and taking total control of their web performance issues. We are collectively learning how to optimize our sites for improved speed, user engagement, visitor retention, conversion rates, SEO ranking, etc ... and Google Lighthouse audits are taking a lot of the guesswork out of this process. With these detailed reports, we now know exactly which aspects of our sites are working for us and which are working against us.

Applying this handy “lazy-loading” strategy to other resources such as JavaScript, HTML, and CSS is just one more way that you can dramatically speed up your site and polish up your score. Stick around to see more of Fly's tactics that can be used to tackle Lighthouse recommendations, improve your scores and get ahead of the competition.