Lazy loading CSS background images for better website loading performance

    People at work

    Every single HTTP request decreases loading performance. For a simple image, the attribute loading="lazy" can be used in order to defer the loading of off-screen images until the image appears on the screen. Using lazy-loading we achieve the following benefits over the traditional embedding in HTML:

    1. Loading speed – with lazy-loading, the page is loaded faster because only images that are visible on the screen are being loaded.
    2. Rendering performance – less loading images reduces the time for page rendering.

    That sounds good. However, while images can be lazy-loaded today then images loaded through CSS can’t. That doesn’t sound like we should give up at this point.

    Let’s examine an example when you want to load the image as a background on a given HTML element:

    <div style="background-image: url('example.webp');">Some example text</div>

    Unfortunately, the background image will be loaded regardless of being on-screen or off-screen. With exception when element <div> has defined display: none;.

    What we need is to stop loading the background image until it’s visible on the screen. Here we can use Intersection Observer API. What is it?

    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.

    The solution

    Let’s then set up two goals:

    1. The solution should be fully automated. We want just to set an attribute data-background-image="URL to image here" on the HTML element.
    2. If Intersection Observer API is not supported we simply load all background images. Note that polyfill for Intersection Observer is available for the browser that doesn’t support it, but for our simplification here we’ll skip this part and just allow the browser to load all background images.

    Step 1: the code then would look like that:

    const initialiseStyleBackgroundIntersectionObserver = () => {
      const lazyBackgrounds = Array.from(document.querySelectorAll('[data-background-image]'));
    
      if (lazyBackgrounds.length === 0) {
        return;
      }
    
      let lazyBackgroundObserver;
    
      const loadBackgroundIfElementOnScreen = (entry) => {
        if (entry.isIntersecting) {
          entry.target.style.backgroundImage = `url('${entry.target.dataset.backgroundImage}')`;
          lazyBackgroundObserver.unobserve(entry.target);
        }
      };
    
      const observeElementVisibility = (lazyBackground) => {
        lazyBackgroundObserver.observe(lazyBackground);
      };
    
      const setBackground = (element) => {
        element.style.backgroundImage = `url('${entry.target.dataset.backgroundImage}')`;
      };
    
      if (typeof window.IntersectionObserver === 'function') {
        lazyBackgroundObserver = new IntersectionObserver((entries) => {
          entries.forEach(loadBackgroundIfElementOnScreen);
        });
        lazyBackgrounds.forEach(observeElementVisibility);
      } else {
        lazyBackgrounds.forEach(setBackground);
      }
    };

    Step 2: we need to run this code at a specific time. Here are the two options:

    1. When the DOM is created determined by DOMContentLoaded event. At this point, the initial HTML document has been completely loaded and parsed, without waiting for stylesheets, images, and subframes to finish loading.
    2. When the page is fully loaded determined by onload event. At this point, all of the objects in the document are in the DOM, and all the images, scripts, links, and sub-frames have finished loading.

    For our example we’ll use DOMContentLoaded event:

    if (typeof document.readyState === 'string' && document.readyState === 'complete') {
      initialiseStyleBackgroundIntersectionObserver();
    } else {
      document.addEventListener('DOMContentLoaded', initialiseStyleBackgroundIntersectionObserver, true);
    }

    Why that way? Why not just only document.addEventListener('DOMContentLoaded', initialiseStyleBackgroundIntersectionObserver, true);. The reason is that your page might be loaded and parsed faster than you add the listener to the DOMContentLoaded event and by that the code won’t run ever. To fix it we need to check first if the page is already loaded and parsed and decide about executing code immediately or adding an event listener.

    Leave a Reply