JavaScript 54 views

Lazy Loading with Intersection Observer

Efficiently load images only when they enter the viewport for better performance.

By TWC Team • Jan 25, 2026

Code

/**
 * Lazy-load images using IntersectionObserver for better performance.
 * Usage: add class="lazy" and data-src="actual.jpg" to <img>; optional data-srcset/data-sizes.
 */
(() => {
  'use strict';

  // Select all images with the 'lazy' class
  const lazyImages = Array.from(document.querySelectorAll('img.lazy'));

  // Options for the Intersection Observer
  const options = {
    root: null, // use viewport as the root
    rootMargin: '0px',
    threshold: 0.1 // trigger when 10% is in view
  };

  // Preload a URL to ensure the request succeeds before swapping attributes (avoids flicker/broken UI)
  const preload = (url) =>
    new Promise((resolve, reject) => {
      if (!url) return reject(new Error('Missing data-src for lazy image.'));
      const img = new Image();
      img.onload = () => resolve();
      img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
      img.src = url;
    });

  // Function to load image
  const loadImage = async (image) => {
    try {
      if (!(image instanceof HTMLImageElement)) throw new TypeError('Target is not an <img>.');
      const { src, srcset, sizes } = image.dataset;

      // If already loaded or missing required data, bail early
      if (!src || image.src === src) return;

      // Preload primary src; browser will handle srcset after swap
      await preload(src);

      if (srcset) image.srcset = srcset;
      if (sizes) image.sizes = sizes;
      image.src = src; // Load the image

      image.classList.remove('lazy'); // Remove the lazy class
      image.removeAttribute('data-src');
      image.removeAttribute('data-srcset');
      image.removeAttribute('data-sizes');
    } catch (error) {
      // Fail gracefully: keep placeholder and mark for debugging/monitoring
      image.dataset.lazyError = 'true';
      console.error('[lazy-load]', error);
    }
  };

  const init = () => {
    if (lazyImages.length === 0) return;

    // If IntersectionObserver isn't supported, load all immediately (functional fallback)
    if (!('IntersectionObserver' in window)) {
      lazyImages.forEach((img) => void loadImage(img));
      return;
    }

    // Create the Intersection Observer
    const observer = new IntersectionObserver((entries, obs) => {
      entries
        .filter(({ isIntersecting }) => isIntersecting)
        .forEach(({ target }) => {
          void loadImage(target);
          obs.unobserve(target); // Stop observing
        });
    }, options);

    // Observe each lazy image
    lazyImages.forEach((image) => observer.observe(image));
  };

  // Initialize after DOM is ready (avoids querying before elements exist)
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }

  // Example usage:
  // <img class="lazy" src="placeholder.jpg" data-src="image.jpg" data-srcset="image@2x.jpg 2x" alt="..." />
})();
Back to Snippets