Skip to Content

Lazily Lazy Loading

Crafting a lazy loader for lazy folks

Lazy loading is one of the best accessibility wins available to front-end developers. By deferring the loading of assets until we need them, we save our users’ data—and consequently their money. For folks who pay disproportionately more for their data, this can be invaluable.

In this tutorial we will craft a lazy loader that can be deployed with minimal effort on any site. Because we’re lazy folks. So we’ll leverage the MutationObserver and IntersectionObserver interfaces to lazily lazy load lazy-loadable elements whenever they’re added to the DOM.

Please feel free to skip to the result at this GitHub repository.

Getting started

Our minimum viable product should:

  • Use vanilla JavaScript without build steps or external dependencies
  • Require no markup changes except for a <script> in the <head>
  • Prevent every <iframe> and <img> from loading until they’re visible
  • Be encapsulated such that it could be deployed on any site
  • Leverage feature detection to target current browsers
  • Be compatible with IE11 when combined with the appropriate polyfills
  • Progressively enhance browsers that support the loading attribute, which will soon give us lazy loading for free 🎉
  • Provide a helpful public interface for fellow lazy folks

Selecting a syntax

According to the ECMAScript compatibility table, we should primarily rely on ES5 language features for IE11 support. In other words, we can’t use syntactic sugar like arrow functions or the spread operator. So we’re stuck with how it was before JavsScript became cool again.

Picking a pattern

Our goals inform us that we’re seeking something with privacy. It should give us space to work without interfering with our users’ sites. But it should also provide a point of entry for troubleshooting or extension.

The revealing module pattern is especially helpful for this. Essentially it’s an immediately invoked function which returns a public interface that has closure over its private values. So it’s a facade for another sandbox.

Here we’ve initialized the Lazily global with our pattern:

Figure 1
const Lazily = (function IIFE(undefined) {
  'use strict'

  // TODO: Private sandbox goes here

  return {
    // TODO: Public interface goes here
  }
})()

Sniffing for features

Our first priority inside our sandbox is determining whether the browser can do this at all.

Feature detection is asking the browser what we get for free and acting accordingly. So we should test whether we can observe elements for mutations and intersections. To do our lazy loading things.

This is a good use case for the in operator because we can combine our requirements into a compound expression and receive a boolean constant:

Figure 2
const isSupported = 'IntersectionObserver' in window
  && 'MutationObserver' in window

Our pattern requires us to return at the end, so we can’t return early if isSupported is false. Instead we’ll use this constant to protect against exceptions and trigger our initialization logic.

Embracing laziness

Typical lazy loading libraries require markup changes that expect all browsers to behave equally. This has its disadvantages:

  • Template logic changes
  • Mixing markup with presentational logic
  • No fallbacks for users with outdated browsers or JavaScript disabled

Our library will leverage a MutationObserver to dynamically process the document as it loads, adjusting the attributes of its target elements as needed. Effortlessly we’ll keep our markup clean and semantic while our library handles the rest.

Let’s define one if it’s supported. Whenever an element it’s observing changes, it will execute our onMutation() callback function:

Figure 3
const mutationObserver = isSupported
  ? new MutationObserver(onMutation)
  : undefined

Our callback will receive an array of MutationRecord objects. Their addedNodes property is a NodeList, which we’ll convert to an array in order to iterate over them. For each Node, we’ll call our initialize() function if it’s an <iframe> or an <img>:

Figure 4
function onMutation(entries) {
  entries.forEach(function (entry) {
    [].slice.call(
      entry.addedNodes
    ).forEach(function (node) {
      if (node instanceof HTMLIFrameElement || node instanceof HTMLImageElement) {
        initialize(node)
      }
    })
  })
}

The eponymous lazy

We’ll need to keep track of which elements we initialize(). This is a good use case for custom data attributes because we can keep this state local to the element and target them with CSS. Let’s assign its key to a constant:

Figure 5
const initializedKey = 'lazily'

We’ll also leverage custom data attributes to lazy load our target elements. To prevent them from loading, we’ll replace critical attributes with custom ones. When it’s time to load we’ll perform the inverse operation. Let’s define our lazyAttributes as an array so we can easily iterate over them:

Figure 6
const lazyAttributes = ['src', 'srcset']

Our initialize() function returns early if the element has already been initialized. Otherwise it replaces our lazyAttributes with custom attributes and tells our IntersectionObserver to start watching:

Figure 7
function initialize(element) {
  if (initializedKey in element.dataset) {
    return
  }

  element.dataset[initializedKey] = ''

  // NOTE: Some browsers provide this for free
  // TODO: Use native lazy loading when supported

  lazyAttributes.forEach(function swapToData(key) {
    if (element.hasAttribute(key)) {
      element.dataset[key] = element[key]
      element.removeAttribute(key)
    }
  })

  intersectionObserver.observe(element)
}

Preferring native implementations

Progressive enhancement is where we apply what we learn from feature detection to improve a baseline experience. This library is a clear example because it enhances browsers that provide its requirements.

We can enhance this further if the element supports the loading attribute. Let’s replace that TODO comment in Figure 7:

Figure 8
if ('loading' in element) {
  if (!element.hasAttribute('loading')) {
    element.setAttribute('loading', 'lazy')
  }
  return
}

This will force lazy loading on elements whose loading attribute is not explicitly set. The early return skips our custom attributes and the next observer entirely. A modern browser will handle the rest.

Classical lazy loading

For everyone else, we’ll resort to using an IntersectionObserver to detect when to revert our elements’ attributes. This is where the classic lazy loading strategy usually begins on the front end.

Let’s define one if it’s supported. It will call our onIntersection() callback function whenever an element it’s observing enters or exits the viewport:

Figure 9
const intersectionObserver = isSupported
  ? new IntersectionObserver(onIntersection, {rootMargin: '50%'})
  : undefined

Our callback will receive an array of IntersectionObserverEntry objects. Their isIntersecting property reports whether the element is currently visible. For each visible entry, we’ll unobserve() their target to load() them only once:

Figure 10
function onIntersection(entries, observer) {
  entries.forEach(function (entry) {
    if (entry.isIntersecting) {
      load(entry.target)
      observer.unobserve(entry.target)
    }
  })
}

…and the eponymous load

To load() our elements, we’ll iterate over our lazyAttributes and replace any custom attributes with their originals:

Figure 11
function load(element) {
  lazyAttributes.forEach(function swapFromData(key) {
    if (key in element.dataset) {
      element[key] = element.dataset[key]
      delete element.dataset[key]
    }
  })
}

Putting it together

With all the pieces built, our MutationObserver is ready to watch the entire document for changes. By observing its subtree, we receive callbacks for all of its descendants too:

Figure 12
if (isSupported) {
  mutationObserver.observe(document.documentElement, {
    childList: true,
    subtree: true,
  })
  // FIXME: Boss thinks the printer is broken
}

Improving printer-friendliness

We should ensure that pending elements also have their attributes reverted when printed. Otherwise they won’t be present in the output.

According to MDN, the beforeprint event is not implemented by Safari browsers. But they offer us a polyfill in the form of window.matchMedia(). We can engineer this to forceLoad() everything when printing the page.

Let’s replace that FIXME comment in Figure 12:

Figure 13
window.matchMedia('print').addListener(function (e) {
  if (e.matches) {
    forceLoad()
  }
})

Our forceLoad() function uses the takeRecords() method to dequeue elements from our IntersectionObserver and load() them:

Figure 14
function forceLoad() {
  [].slice.call(
    intersectionObserver.takeRecords()
  ).forEach(function (entry) {
    load(entry.target)
  })
}

Perhaps someone may find forceLoad() useful. Let’s make it public.

Providing a public interface

When we’re done in our sandbox we can—at last—return a small interface. So users can interrogate it in their implementations. We can provide additional safety with indirection; by avoiding direct references to our sandbox’s values, we maintain its purity:

Figure 15
return {
  forceLoad: function () {
    if (isSupported) {
      forceLoad()
    }

    return this
  },
  isSupported: function () {
    return isSupported
  },
}

For now it’s sparse but effective.

Deploying your lazy loader

Typical lazy loading libraries require users to make costly changes to their markup. Our strategy of leveraging a MutationObserver to react when our target elements are added to the document means that deployment couldn’t be simpler:

Figure 16
<head>
  <title>99 Bottles</title>
  <script src="path/to/library.js"></script>
</head>

As long as it’s placed within the document <head>, our library will automatically process the entire <body>, including across asynchronous navigations. That’s great news for lazy folks.

Next steps

This is only the beginning of a successful lazy loading library.

Documentation is a must, including: a README file, JSDoc comment blocks, a license, and contribution guidelines if you prefer. Likewise, integrations testing would prove its efficacy and help prevent breaking changes. And don’t forget to publish it through all the major channels.

A lacking feature is support for <picture> and <video> elements. An IntersectionObserver will never report visibility changes for their child <source> elements because they have no geometry. So we can’t simply observe them like <img> and <iframe>.

Ultimately our abstraction isn’t expressive enough because its solutions are a maze of conditional statements. Refactoring how we handle different elements to a command pattern would afford us more control and clarity.

Do you have the energy for any of that?