Skip to main content

Optimise Your Page Loads with Lazy Loading Javascript

How to optimise your progressively decoupled Drupal frontend with the new Intersection Observer API.

by rikki.bochow /

Read on for front-end tips backed by code you can use! You can also watch the full video at the end of this post. 

What is Lazy Loading?

Lazy Loading isn’t a new concept; however, let's quickly recap what we know about it.

“Lazy loading is a strategy to identify resources as non-blocking (non-critical) and load these only when needed. It’s a way to shorten the length of the critical rendering path, which translates into reduced page load times.” Mozilla Developer Network.

Why is lazy loading significant?

Good performance offers many benefits.

How often have you given up and closed a web page because it took too long to load? Especially when you’re on your mobile or experiencing a poor connection. It’s easy to forget that not everyone has regular access to fast, reliable internet connections or devices.

There are plenty of benefits to lazy loading:

  • Improved initial page load times
  • Better perceived performance
  • Decreased data usage
  • Positive impact on SEO rankings
  • Higher conversion rates
  • Improved user experience

If you’d like to dive deeper into these metrics, check out Jake Archibald’s F1 series for before and after speed tests.

The basic principles of lazy loading

Stylesheets

Because stylesheet files are render-blocking, we need to determine what is critical or above-the-fold CSS and inline it. We then defer the rest with Javascript attribute swapping and use Drupal’s Library system to reduce unused CSS.

Javascript

We also need to determine our critical Javascript and consider inlining it. Definitely defer any JS that isn’t critical and load asynchronously where applicable. 

ES6 modules are deferred by default and supported by modern browsers, so can be combined with code splitting. Again, we can use Drupal’s Library system to reduce unused Javascript.

Media

Media can slow down pages too. That’s why the loading attribute is gaining support in both Drupal and browsers. 

<img loading=”lazy”> has the most comprehensive support, so you should avoid using Javascript for these and also avoid lazy loading images that are likely to be above the fold.

Always put height and width attributes to prevent layout shift and use the responsive images module.

But we want to lazy load more!

And with the Intersection Observer API, we can. 

So what is the Intersection Observer API?

“The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport.” Mozilla Developer Network

Simply put, it means: “tell me when an element has scrolled into or out of view”.

This API isn’t new (having been around for roughly five years); however, the recent demise of Internet Explorer now means it has full browser support.

iframes

Let's take iframe in Firefox as an example. If you have a lot of iframes (because, let’s face it, sometimes you just need to) and you’d like to save your Firefox users from long pages loads, then you can use oembed_lazyload (which uses Intersection Observer).

Alternatively, you can write a very simple Intersection Observer to populate the iframe src attribute from the data-src value.

In the video below, I ran through a basic introduction to the Intersection Observer API. I used all the default settings and checked the isIntersecting property, swapping the attributes, and then Unobserving the iframe (so it didn’t trigger again when it scrolled into the viewport subsequent times).

// <iframe data-src="url" height="600" width="800" />
const lazyLoadIframe = iframe => {
 const obs = new IntersectionObserver(items => {
   items.forEach(({ isIntersecting }) => {
     if (isIntersecting) {
       iframe.src = iframe.dataset.src
       obs.unobserve(iframe)
     }
   })
 })
 obs.observe(iframe)
}
window.addEventListener('load', () => { 
 document.querySelectorAll('iframe').forEach(iframe => lazyLoadiFrame(iframe))
})

Javascript applications

We can expand on this idea of deferring assets that are below the fold and think about our progressively or partially decoupled projects.

In the following example, we’ll imagine a Drupal site with complex javascript applications embedded here and there. The apps are written in React, Vue or even Vanilla JS; they fetch data from Drupal as a JSON file and load their CSS. There may also be multiple apps on a page.

If we load these apps, as usual, we’ll load everything, including dependencies (JSON, CSS etc.) on the initial page load, regardless of whether we defer or async the javascript. It’s not render-blocking, but users who don’t scroll down to see the App are still downloading it.

Instead, we can combine the Intersection Observer with a Dynamic Import and truly defer loading all the apps’ resources until the mounting element is in the user's viewport. 

In the below code example, the load() function is only called upon intersection, so none of the apps’ dependencies are requested until the container scrolls into the viewport, significantly decreasing the initial page load time.

React example;

const lazyLoadApp = (
  container, 
  componentPath, 
  props = {}, 
  callback = () => []
) => {
  
  const load = async () => Promise.all([
    import("react-dom"),
    import("react"),
    import(componentPath),
  ]).then(([
    { render },
    React, 
    { default: Component }
  ]) => 
    render(<Component {...props} />, container, callback)
  )
  const obs = new IntersectionObserver(items => {
    items.forEach(({ isIntersecting }) => {
      if (isIntersecting) {
        load()
        obs.unobserve(container)
      }
    })
  })
  obs.observe(container)
}

We use Promise.all to ensure all of our dependencies are met, and then we destructure what we need from those dependencies and render our app.

After this happens, we unobserve the container.

You can also adjust the load() function as needed, i.e. import Vue and createApp instead–whatever your setup requires. 

Example for Vue 3;

const load = async () => Promise.all([
  import("vue"),
  import(componentPath),
]).then(([
  { createApp },
  { default: Component }
]) => {
  const app = createApp(Component, props)
  callback(app)
  app.mount(container)
})

Example for React 18;

const load = async () => Promise.all([
  import("react-dom/client"),
  import("react"),
  import(componentPath),
]).then(([
  { createRoot },
  React, 
  { default: Component }
]) => {
  const root = createRoot(container)
  root.render(<Component {...props} />)
  // callback function moves into Component.
})

Then usage would be something like:

// <div data-app-example id="my-app" data-title="Hello Drupal South" />
window.addEventListener('load', () => { 
  document.querySelectorAll('[data-app-example]').forEach(container =>
    lazyLoadApp(
      container,
      './path-to/component.jsx',
      {
        title: container.dataset.title,
        id: container.id,
      },
      () => container.setAttribute('data-mounted', true)
    ))
})

Here's the breakdown;

  • pass in the container (div to render/mount to)
  • the path to a specific component
  • any props needed (maybe simple data attributes or drupalSettings)
  • even a callback function after mounting has occurred

Accessibility considerations

We need to remember that the WCAG have findability rules for hidden content. Adding a heading (maybe even a description) inside the container with a button that triggers the load function might help with this. They get replaced by the rendered app but are available for screen readers and keyboard navigation.

You’ll also need to consider the following: 

  • How important is the content in the JS app?
  • Is the content shown elsewhere, or can it be?
  • Is the app itself accessible?
  • What will happen if JS isn’t enabled?

UX considerations

The unmounted container is also a good use case for Skeleton User Interfaces. Start by giving the container a grey background with a rough height/width for the rendered app, then add a loading animation, and you’ll help reduce the “jump” of the suddenly rendered app whilst also improving the perceived performance. 

This approach is also a great way to prevent Layout Shift issues. Also, remember to notify the user if something has failed to load.

You can tweak the Intersection Observer’s settings to increase or decrease the point of intersection, allowing for sticky headers, for example.

What else can we do with the Intersection Observer?

Other use cases for the Intersection Observer include:

  • Scroll-spy type components that update sticky anchor navigation
  • Animations that only start once in the viewport or should stop once outside the viewport.
  • Infinite scrolling pagination
  • Pausing videos when scrolled passed
  • Bookmarking where an article has been read to

Let’s not forget that the post-Internet Explorer world is full of Observers, including:

  • Mutation Observer. For DOM changes, such as attributes or markup/content injection (i.e. knowing when one app has rendered).
  • Resize Observer. A more performant version of the window.resize event for element dimension changes.

These all follow the same Observe/Unobserve pattern, so once you learn one, you won’t be able to stop yourself from using them all!