Lazy loading CSS background images for better website loading performance

    People at work

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

    1. Loading speed – with lazy-loading, page is loaded faster because only images that are visible on the screen are being loaded.
    2. Rendering performance – less loading images reduces 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 an image 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.

    Let’s then setup 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 Intersection Observer can have a polyfill for a browser that doesn’t support it, but for our simplification here we’ll skip this part and just allow browser to load all background images.

    Step 1: the code then would look like that:

    const initialiseStyleBackgroundIntersectionObserver = () => {
      const lazyBackgrounds = 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);
        });
        Array.from(lazyBackgrounds).forEach(observeElementVisibility);
      } else {
        Array.from(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, 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 because your page might be loaded and parsed faster then 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 add event listener and wait.

    Leave a Reply