Skip to main content

Lazy Load Background Images with the IntersectionObserver API

March 12, 2025 · Updated on · 12 min read
Anna Monus

While we can defer offscreen images using the loading HTML attribute, lazy loading background images takes a bit more work. Since they are added by CSS rather than HTML, we need to use JavaScript to detect when offscreen background images are about to enter the user's viewport.

It would be nice to have a native background-loading: lazy property in CSS as well, but unfortunately, it doesn't currently exist. Luckily, the IntersectionObserver API provides a performance-friendly solution to lazy load background images without having to manually add JavaScript event listeners and perform viewport calculations, or use a third-party library.

In this article, we'll look into how to lazy load background images using the background CSS property and the IntersectionObserver JavaScript API.

What Is the IntersectionObserver API?

IntersectionObserver is a native web API available in modern browsers, so you can use it without having to add a third-party library to your page. It allows you to asynchronously monitor when an HTML element visually overlaps (a.k.a. intersects) with one of its ancestors, such as the viewport.

This makes the IntersectionObserver API an excellent way to detect when offscreen elements are scrolling into view, thus one of its most frequent use cases is lazy loading background images.

As IntersectionObserver is supported by all modern browsers, you can use it without a fallback.

How Does the IntersectionObserver API Improve Web Performance?

As the IntersectionObserver() object runs asynchronously outside the main thread, it provides a performance-friendly way to monitor the visibility of HTML elements, avoiding the costly DOM queries and layout calculations that traditional scroll event listeners trigger on each scroll.

Before the IntersectionObserver API, developers had to implement lazy loading manually by attaching event listeners to scroll, change, resize, and other viewport-related events.

This approach was inefficient because these events fire extremely frequently during user interaction, and each event executes expensive DOM queries on the main thread, blocking it repeatedly. Even with throttling techniques, such as using the setTimeout() function, the performance overhead remains significant.

The IntersectionObserver API solves these web performance issues by making native lazy loading possible. Unlike manual event listeners, it uses the browser's compositor thread for intersection calculations and batches multiple intersection changes that happen close in time into a single callback, avoiding repeated DOM calculations. This results in reduced CPU usage, smoother scrolling, and better user experience.

Background Info

The IntersectionObserver API is also used internally by the loading="lazy" HTML attribute you can add to <img> and <iframe> elements. This is why it's (much) easier to lazy load offscreen images than background images — instead of having to initialize an IntersectionObserver object, you just add the attribute to the image you want to lazy load, and you're good to go.

To learn more about the loading attribute, check out the relevant part of the HTML standards or our comprehensive guide on how to defer offscreen images.

Now let's see how to lazy load background images using the IntersectionObserver API.

What Will We Build?

To see how to defer offscreen background images using the IntersectionObserver API in practice, I created a demo page that includes some CTA (call to action) elements with background images added in CSS in a vertical layout. The background images are lazy loaded from the fourth image onward.

Below, you can see a screencast of how the resources, including the deferred background images, download from the network in the Chrome browser. Note that the first two images are preloaded, which is why they download before the CSS and JavaScript files:

Now let's see how to create the above demo using the IntersectionObserver API.

1. Mark the Offscreen Elements with a Dedicated Class in HTML

To defer offscreen background images, you can use the following HTML:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Defer Offscreen Background Images</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="preload"
href="images/flowers-01.jpg"
as="image"
fetchpriority="high"
/>
<link
rel="preload"
href="images/flowers-02.jpg"
as="image"
fetchpriority="high"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="bg-1"><p>CTA Area 01</p></div>
<div class="bg-2"><p>CTA Area 02</p></div>
<div class="bg-3"><p>CTA Area 03</p></div>
<div class="bg-4 deferred"><p>CTA Area 04</p></div>
<div class="bg-5 deferred"><p>CTA Area 05</p></div>
<div class="bg-6 deferred"><p>CTA Area 06</p></div>
<div class="bg-7 deferred"><p>CTA Area 07</p></div>
<div class="bg-8 deferred"><p>CTA Area 08</p></div>
<div class="bg-9 deferred"><p>CTA Area 09</p></div>
<script src="script.js"></script>
</body>
</html>

The code above adds the deferred class to the offscreen <div> elements starting from the fourth one. On mobile viewports, the first three elements typically appear above the fold, so we can safely lazy load the background images up from the fourth element.

The process of lazy loading will use the following workflow:

  1. We'll show a simple gray background color for the .deferred elements as a visual placeholder using CSS.
  2. We'll monitor the current positions of the deferred elements in JavaScript, using the IntersectionObserver API.
  3. When an observed <div> gets close to the viewport (as the user is scrolling down the page), we'll remove the deferred class from the HTML using JavaScript.
  4. When the deferred class is removed, the CSS will swap the gray background color with the background image.
note

As you can see above, the HTML also preloads the first two images using the <link> element and sets fetchpriority to high. While I hadn't thought about this first, DebugBear warned me that the LCP image should be preloaded with high priority when I tested the first version of the demo — this is a good example of why we use DebugBear at DebugBear to run DebugBear.

Since in this demo, it's either the first or second image that's reported as the LCP element, I preloaded both (however, note that too many preloads may backfire, so be careful when using preloading):

Request waterfall showing the preloaded background images

2. Override the Deferred Background Images with a Background Color in CSS

The CSS below uses a simple but efficient technique to swap the placeholder background color with the background image when we remove the deferred class with JavaScript — the cascade (CSS stands for 'Cascading Style Sheets' since the cascade is its most fundamental feature):

div {
max-width: 90vw;
margin: 1.25rem auto;
aspect-ratio: 800 / 533;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.bg-1 {
background: #d6d6d6 url("images/flowers-01.jpg") no-repeat;
}
.bg-2 {
background: #d6d6d6 url("images/flowers-02.jpg") no-repeat;
}
.bg-3 {
background: #d6d6d6 url("images/flowers-03.jpg") no-repeat;
}
.bg-4 {
background: #d6d6d6 url("images/flowers-04.jpg") no-repeat;
}
.bg-5 {
background: #d6d6d6 url("images/flowers-05.jpg") no-repeat;
}
.bg-6 {
background: #d6d6d6 url("images/flowers-06.jpg") no-repeat;
}
/* ... */
.deferred {
background-image: none;
}

The code above first defines the backgrounds for the individual <div> elements (.bg-1 , .bg-2, etc.) using the shorthand background property, which:

  • first adds a light gray background color (#d6d6d6), which will serve as the visual placeholder,
  • then specifies the location of the background image (url(...)),
  • finally prevents the repetition of the background image (no-repeat).

Then, it adds the .deferred class below the individual elements to leverage the cascade in the following way:

  1. The background-image: none rule will apply to the .deferred divs because it's located below the background image rules defined in the shorthand background properties of the .bg-1, .bg-2, etc. declarations, so it will override them.
  2. The background color (#d6d6d6) defined in the shorthand background properties will still apply to the .deferred divs, as the CSS doesn't specify a separate background-color property for them.
  3. When the .deferred class gets removed from the HTML, the background-image: none rule won't apply to the previously deferred divs anymore, so the background images defined for the individual elements in the shorthand background properties will start downloading from the network and then appear on the screen.

The code above also adds the aspect-ratio property to the div elements (it's equal to the ratio of the intrinsic width and height of the image files) so that they'll have the same dimensions as the background images.

Note that without defining aspect-ratio, the containers won't be the same size as the background images — see an example in the screenshot below (which is OK, if this is the design you want to achieve):

Background images without aspect ratio

3. Observe the Intersection State of the Deferred Elements and Viewport in JavaScript

In the JavaScript code, we'll listen to the DOMContentLoaded event, which fires when the HTML page is completely parsed and the DOM is ready to use. This ensures all the HTML elements are available in the DOM before we try to select and observe them:

document.addEventListener("DOMContentLoaded", () => {
// selects and stores the deferred elements
const deferredElements = document.querySelectorAll(".deferred");

// creates the observer
const elementObserver = new IntersectionObserver(
(entries, observer) =>
// callback function
{
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.remove("deferred");
observer.unobserve(entry.target);
}
});
},

// properties of the observer
{
root: null,
rootMargin: "200px 0px",
threshold: 0,
}
);

// starts observing the deferred elements
deferredElements.forEach((element) => elementObserver.observe(element));
});

After setting up the DOMContentLoaded event listener, the above script selects all elements with the deferred class and adds them to the deferredElements constant (which is a NodeList object that will include all the deferred div elements).

Next, it instantiates an IntersectionObserver() object and assigns it to the elementObserver constant, which will be created when the DOMContentLoaded event fires.

The script also defines three properties for elementObserver:

  • root:
    • It specifies the element against which the positions of the observed elements will be compared.
    • The value null sets the viewport as the root element.
  • rootMargin:
    • It creates a margin around the root element (here, the viewport) so that the observation can start before it intersects with the observed element.
    • It's a shorthand that works similarly to the margin CSS property.
    • The code above sets it to 200px 0px, which creates a 200px margin at the top and bottom of the viewport, so the deferred images will start downloading 200px before they enter the viewport.
    • You can also use a different value if you want to start the download process sooner or later (e.g. 500px will start it sooner).
  • threshold:
    • It specifies the intersection threshold when the callback function will be executed.
    • The value 0 means that it starts executing immediately when an observed div enters the visibility zone (which is the root extended with the rootMargin).
    • Otherwise, threshold must be a decimal value between 0 and 1 (e.g. 0.1 would mean that the callback function is executed when an observed element has reached the 10% of the visibility zone).

The elementObserver object also starts observing the deferred div elements stored in the deferredElements constant when the DOMContentLoaded event fires — this functionality is set up in the forEach() loop at the end of the script.

What Does the Callback Function Do?

Whenever a deferred element's visibility state changes (i.e., when it intersects with the visibility area of the root element), the elementObserver object:

  1. creates a new IntersectionObserverEntry object (called entry in the code above),
  2. adds the new entry to the entries array,
  3. executes the callback function.
info

Since IntersectionObserver is a native browser API, we don't have to instantiate the IntersectionObserverEntry() objects manually, as the browser handles this task internally.

The callback function runs a forEach() loop on the entries array and checks whether each entry in the array is intersecting with the visibility area of root (which happens when the isIntersecting property of the entry is true).

If so, it removes the deferred class of the belonging div from the HTML and instructs elementObserver to stop observing the element.

4. Add a CSS Fallback for Users Who Have JavaScript Disabled

To support users who have JavaScript disabled in their browsers, you can add the following line of code below the <link rel="stylesheet" href="style.css"> tag in the <head> section of the HTML page:

<noscript>
<style>
.bg-4 {
background-image: url(images/flowers-04.jpg);
}
.bg-5 {
background-image: url(images/flowers-05.jpg);
}
.bg-6 {
background-image: url(images/flowers-06.jpg);
}
.bg-7 {
background-image: url(images/flowers-07.jpg);
}
.bg-8 {
background-image: url(images/flowers-08.jpg);
}
.bg-9 {
background-image: url(images/flowers-09.jpg);
}
</style>
</noscript>

The code above overrides the background-image rules of the CSS file when the <noscript> tag is active (which happens when JavaScript is disabled in the user's browser), and downloads and displays the background images for non-JavaScript users who otherwise would just see the gray placeholder boxes in place of the deferred background images.

On the other hand, note that if you add the above code, the background images won't be lazy loaded for users who have JavaScript disabled in their browsers, but they will all start downloading at initial page load.

Summary: How to Lazy Load Background Images with the IntersectionObserver API

The IntersectionObserver API provides a modern and performance-friendly way to lazy load background images by using the compositor thread to asynchronously observe the location of offscreen elements, instead of blocking the main thread. By deferring offscreen background images, you can reduce page weight, speed up page load times, and improve Core Web Vitals.

With DebugBear, you can test the performance impact of web development best practices such as lazy loading background images, check how fast your pages load overall, catch web performance issues proactively, set up both synthetic tests and real user monitoring (RUM), and more.

You can get started for free by running a free website speed test, checking out our interactive demo, or signing up for a free 14-day trial for the full functionality (no credit card required).

Illustration of website monitoringIllustration of website monitoring

Monitor Page Speed & Core Web Vitals

DebugBear monitoring includes:

  • In-depth Page Speed Reports
  • Automated Recommendations
  • Real User Analytics Data

Get a monthly email with page speed tips