Lazy-Loading Images and Modules with Intersection Observer

Table of Contents
Big thanks to our contributors those make our blogs possible.

Our growing community of contributors bring their unique insights from around the world to power our blog. 

Introduction

Web performance is no longer a luxury—it’s mission-critical. As pages grow richer with high-resolution images, videos, and JavaScript features, initial load times and Time to Interactive (TTI) can worsen dramatically. Lazy-loading defers the loading of non-critical resources until they’re actually needed, reducing initial payload and speeding up page render. The Intersection Observer API provides a modern, efficient mechanism to trigger lazy-loading when elements scroll into view, without the overhead of scroll-event handlers. In this guide, we’ll explore:

  1. Why lazy-loading matters
  2. The fundamentals of Intersection Observer
  3. Lazy-loading images and media
  4. Lazy-loading JavaScript modules
  5. Best practices and edge-case handling
  6. Browser support, polyfills, and testing

By the end, you’ll have everything you need to implement performant, maintainable lazy-loading for both visual assets and code modules.

1. Why Lazy-Loading Matters

1.1 Performance Benefits

  • Reduced initial payload: Fewer bytes downloaded during first load, improving metrics like First Contentful Paint (FCP).
  • Lower memory usage: Images and scripts aren’t held in memory until needed.
  • Faster TTI: Deferring non-critical JavaScript means the main thread remains free to respond to user interaction sooner.

1.2 UX Advantages

  • Progressive rendering: Users see above-the-fold content immediately, with offscreen content loading seamlessly as they scroll.
  • Bandwidth savings: Mobile and data-capped users avoid downloading images or code paths they never reach.

2. Intersection Observer Fundamentals

2.1 What It Is

The Intersection Observer API lets you asynchronously observe changes in the intersection of a target element with an ancestor or with the viewport. Instead of listening to scroll and resize events, the browser efficiently notifies you when thresholds are crossed.

2.2 Core Concepts

  • root: The container element to manage intersections against (often null for viewport).
  • rootMargin: Expand or contract the root’s bounding box (e.g., '0px 0px 200px 0px' to preload 200px before it enters view).
  • threshold: A single number or array (0 to 1) indicating the percentage of the target’s visibility required to trigger the callback.

2.3 Basic Usage

jsCopyEditconst observer = new IntersectionObserver((entries, obs) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // element has come into view
      loadResource(entry.target);
      obs.unobserve(entry.target);
    }
  });
}, {
  root: null,           // viewport
  rootMargin: '0px',
  threshold: 0.1        // 10% visible
});

// Observe all targets
document.querySelectorAll('.lazy').forEach(img => {
  observer.observe(img);
});

3. Lazy-Loading Images and Media

3.1 HTML Markup Patterns

Use placeholder attributes and data-attributes to store the real source:

htmlCopyEdit<img
  class="lazy"
  src="placeholder.jpg"
  data-src="highres.jpg"
  alt="Descriptive text"
  width="600" height="400"
/>

3.2 JavaScript Implementation

jsCopyEditfunction loadImage(img) {
  const src = img.getAttribute('data-src');
  if (!src) return;
  img.src = src;
  img.onload = () => img.classList.add('loaded');
}

const imgObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return;
    loadImage(entry.target);
    observer.unobserve(entry.target);
  });
}, { rootMargin: '0px 0px 200px 0px', threshold: 0 });

document.querySelectorAll('img.lazy').forEach(img => {
  imgObserver.observe(img);
});
  • rootMargin: Preload images 200px before they enter viewport.
  • CSS transition: Fade in loaded images: cssCopyEditimg.lazy { opacity: 0; transition: opacity 0.5s ease; } img.lazy.loaded { opacity: 1; }

3.3 Picture Element and Srcset

htmlCopyEdit<picture class="lazy">
  <source data-srcset="image-400.webp 400w, image-800.webp 800w" type="image/webp" />
  <source data-srcset="image-400.jpg 400w, image-800.jpg 800w" type="image/jpeg" />
  <img
    src="placeholder.jpg"
    data-src="image-800.jpg"
    data-srcset="image-400.jpg 400w, image-800.jpg 800w"
    data-sizes="(max-width: 600px) 100vw, 800px"
    alt="Responsive"
    class="lazy"
    width="800" height="600"
  />
</picture>

And then in JavaScript:

jsCopyEditfunction loadPicture(picture) {
  picture.querySelectorAll('source').forEach(source => {
    source.srcset = source.dataset.srcset;
  });
  const img = picture.querySelector('img');
  img.src = img.dataset.src;
  img.srcset = img.dataset.srcset;
  img.sizes = img.dataset.sizes;
}

const pictureObserver = new IntersectionObserver((entries, obs) => {
  entries.forEach(({ target, isIntersecting }) => {
    if (!isIntersecting) return;
    loadPicture(target);
    obs.unobserve(target);
  });
}, { rootMargin: '0px 0px 200px 0px', threshold: 0 });

document.querySelectorAll('picture.lazy').forEach(pic => {
  pictureObserver.observe(pic);
});

4. Lazy-Loading JavaScript Modules

Modern bundlers (Webpack, Rollup) and browsers support dynamic import() to split code:

4.1 Dynamic Import on Intersection

jsCopyEditconst modules = new Map(); // to prevent duplicate loads

function loadModule(el) {
  if (modules.has(el)) return;
  import('./heavyComponent.js')
    .then(module => {
      module.init(el); // mount or initialize component in el
    }).catch(err => console.error('Load failed', err));
  modules.set(el, true);
}

const moduleObserver = new IntersectionObserver((entries, obs) => {
  entries.forEach(({ target, isIntersecting }) => {
    if (isIntersecting) {
      loadModule(target);
      obs.unobserve(target);
    }
  });
}, { rootMargin: '0px 0px 300px 0px', threshold: 0 });

document.querySelectorAll('.lazy-module').forEach(el => {
  moduleObserver.observe(el);
});
  • 300px rootMargin: Preload modules before they scroll into view, ensuring instant interactivity.

4.2 Integrating with Frameworks

  • React: Wrap heavy components with <IntersectionObserver> hooks that call React.lazy() on intersection.
  • Vue: Use a custom directive to dynamically import and mount components when visible.

5. Best Practices and Edge Cases

5.1 Fallback for Unsupported Browsers

Include a simple JavaScript fallback or loading="lazy" attribute on <img> (native support in modern browsers):

htmlCopyEdit<img src="highres.jpg" loading="lazy" alt="">

5.2 Error Handling

  • Image load errors: Listen for onerror to set a fallback image.
  • Module load failures: Show a spinner or retry button.

5.3 Efficient Observer Lifecycles

  • Unobserve targets after load to avoid memory leaks.
  • Batch observes: Create a single observer for images and another for modules, rather than one per element.

5.4 Accessibility Considerations

  • Provide alt text for images to ensure screen readers have context.
  • Ensure critical content isn’t hidden behind lazy-loading if it’s needed immediately (e.g., content above the fold).

6. Browser Support, Polyfills, and Testing

6.1 Support Matrix

  • Intersection Observer: Supported in all modern browsers, including Chrome, Firefox, Edge, Safari 12+; caniuse.com/#feat=intersectionobserver
  • Native loading="lazy": Chrome, Edge, Firefox; Safari added in 15.4.

6.2 Polyfills

For older browsers:

htmlCopyEdit<script src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"></script>

Or install intersection-observer via npm and import it at the top of your bundle.

6.3 Testing

  • DevTools throttling: Simulate slow network to verify lazy-loading triggers correctly.
  • Accessibility audit: Ensure users with JS disabled see all content loaded.
  • Performance monitoring: Measure LCP, TTFB, and memory usage before and after lazy-loading implementation.

Conclusion

Lazy-loading with Intersection Observer offers a powerful, performant way to defer both images and code modules until needed. By combining thoughtful root margins, proper unobserving, and graceful fallbacks, you can dramatically improve page load times, reduce data usage, and enhance user experience. Whether you’re optimizing a blog’s hero images or deferring a heavy analytics dashboard, the patterns covered here scale to any modern web application. Implement these techniques today to unlock faster, leaner, and more responsive experiences for your users.

Let's connect on TikTok

Join our newsletter to stay updated

Sydney Based Software Solutions Professional who is crafting exceptional systems and applications to solve a diverse range of problems for the past 10 years.

Share the Post

Related Posts