Lazy Loading Images with JavaScript IntersectionObserver

The IntersectionObserver API provides a performant, asynchronous mechanism for detecting when an element enters or leaves the browser viewport, making it the ideal solution for lazy loading images. By deferring image requests until elements are actually needed on screen, web applications can significantly reduce initial page load times, decrease bandwidth usage, and eliminate performance bottlenecks traditionally caused by scroll event listeners.

How the IntersectionObserver API Works

Historically, lazy loading required attaching listeners to scroll, resize, or orientationchange events and manually calculating element positions using methods like getBoundingClientRect(). This approach frequently caused layout thrashing and forced synchronous reflows on the browser’s main thread.

The IntersectionObserver API resolves this by delegating visibility detection to the browser’s internal rendering pipeline. It registers a callback function that executes whenever a target element intersects an ancestor element or the top-level document viewport, completely decoupling visibility checks from the main execution thread.

Implementing Lazy Loading Step-by-Step

Implementing lazy loading with IntersectionObserver involves structuring the HTML markup to hold the image source temporarily, configuring the observer, and swapping the source when the image becomes visible.

1. HTML Markup Structure

Instead of placing the actual image URL in the src attribute, store it inside a data-src attribute. This prevents the browser from immediately downloading the image during the initial HTML parse.

<img class="lazy-image" data-src="large-image.jpg" alt="Description" src="placeholder.jpg" />

2. Creating the Observer Instance

Instantiate a new IntersectionObserver by passing a callback function and an optional configuration object:

const imageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.classList.remove('lazy-image');
      observer.unobserve(img);
    }
  });
}, {
  root: null,
  rootMargin: '200px 0px',
  threshold: 0.01
});

3. Attaching Target Elements

Query all deferred image elements in the document and pass each one to the observer’s observe() method:

document.addEventListener('DOMContentLoaded', () => {
  const lazyImages = document.querySelectorAll('.lazy-image');
  lazyImages.forEach(image => imageObserver.observe(image));
});

Key Configuration Options

The IntersectionObserver constructor accepts an options object with three main properties:

Cleaning Up with unobserve()

Once an image source is swapped and loading begins, calling observer.unobserve(img) on that specific element stops monitoring it. This ensures that the callback does not execute unnecessarily on subsequent scrolls, keeping memory and CPU usage to a minimum.