Lazy Loading in JavaScript: Complete Tutorial

A complete tutorial on lazy loading in JavaScript. Covers IntersectionObserver for images, native lazy loading, component lazy loading, module lazy loading, progressive data loading, placeholder strategies, and building a full lazy loading framework.

JavaScriptadvanced
16 min read

Lazy loading defers the loading of resources until they are needed, reducing initial page weight and improving load times. This tutorial covers every lazy loading pattern from images to components to data.

For image-specific patterns and component lazy loading, see How to Lazy Load Images and Components in JS.

IntersectionObserver for Lazy Loading

javascriptjavascript
class LazyLoader {
  constructor(options = {}) {
    this.rootMargin = options.rootMargin || "200px 0px";
    this.threshold = options.threshold || 0;
    this.loaded = new Set();
 
    this.observer = new IntersectionObserver(
      (entries) => this.handleEntries(entries),
      {
        rootMargin: this.rootMargin,
        threshold: this.threshold,
      }
    );
  }
 
  observe(elements) {
    if (typeof elements === "string") {
      elements = document.querySelectorAll(elements);
    }
 
    elements.forEach((el) => {
      if (!this.loaded.has(el)) {
        this.observer.observe(el);
      }
    });
  }
 
  handleEntries(entries) {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        this.loadElement(entry.target);
        this.observer.unobserve(entry.target);
        this.loaded.add(entry.target);
      }
    });
  }
 
  loadElement(el) {
    const tagName = el.tagName.toLowerCase();
 
    switch (tagName) {
      case "img":
        this.loadImage(el);
        break;
      case "video":
        this.loadVideo(el);
        break;
      case "iframe":
        this.loadIframe(el);
        break;
      default:
        this.loadBackground(el);
    }
  }
 
  loadImage(img) {
    const src = img.dataset.src;
    const srcset = img.dataset.srcset;
 
    if (srcset) img.srcset = srcset;
    if (src) {
      img.src = src;
      img.addEventListener("load", () => img.classList.add("loaded"));
      img.addEventListener("error", () => img.classList.add("error"));
    }
  }
 
  loadVideo(video) {
    const sources = video.querySelectorAll("source[data-src]");
    sources.forEach((source) => {
      source.src = source.dataset.src;
    });
    video.load();
  }
 
  loadIframe(iframe) {
    iframe.src = iframe.dataset.src;
  }
 
  loadBackground(el) {
    const bg = el.dataset.bg;
    if (bg) {
      el.style.backgroundImage = `url('${bg}')`;
      el.classList.add("loaded");
    }
  }
 
  disconnect() {
    this.observer.disconnect();
  }
}
 
// Usage
const loader = new LazyLoader({ rootMargin: "300px 0px" });
loader.observe("[data-src]");

Native Lazy Loading

javascriptjavascript
// HTML native lazy loading (images and iframes)
// <img src="photo.jpg" loading="lazy" alt="Photo">
// <iframe src="video.html" loading="lazy"></iframe>
 
// Progressive enhancement: use native when available, polyfill otherwise
function setupLazyImages() {
  const images = document.querySelectorAll("img[data-src]");
 
  if ("loading" in HTMLImageElement.prototype) {
    // Browser supports native lazy loading
    images.forEach((img) => {
      img.src = img.dataset.src;
      if (img.dataset.srcset) {
        img.srcset = img.dataset.srcset;
      }
      img.loading = "lazy";
    });
    console.log("Using native lazy loading");
  } else {
    // Fallback to IntersectionObserver
    const loader = new LazyLoader();
    loader.observe(images);
    console.log("Using IntersectionObserver lazy loading");
  }
}
 
// Eager vs lazy loading decision
function optimizeImageLoading() {
  const images = document.querySelectorAll("img[data-src]");
 
  images.forEach((img) => {
    const rect = img.getBoundingClientRect();
    const isAboveFold = rect.top < window.innerHeight;
 
    img.src = img.dataset.src;
 
    // Above fold: load eagerly for better LCP
    // Below fold: load lazily to save bandwidth
    img.loading = isAboveFold ? "eager" : "lazy";
 
    // Optionally add fetchpriority for hero images
    if (img.dataset.hero) {
      img.fetchPriority = "high";
    }
  });
}

Component Lazy Loading

javascriptjavascript
class LazyComponent {
  constructor(container, loader, options = {}) {
    this.container = typeof container === "string"
      ? document.querySelector(container)
      : container;
    this.loader = loader;
    this.placeholder = options.placeholder || this.defaultPlaceholder();
    this.loadOnVisible = options.loadOnVisible !== false;
    this.module = null;
    this.observer = null;
  }
 
  defaultPlaceholder() {
    const el = document.createElement("div");
    el.className = "lazy-placeholder";
    el.style.cssText = "min-height:200px;background:#f0f0f0;border-radius:8px;";
    return el;
  }
 
  init() {
    this.container.appendChild(this.placeholder);
 
    if (this.loadOnVisible) {
      this.observer = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting) {
            this.load();
            this.observer.disconnect();
          }
        },
        { rootMargin: "100px" }
      );
      this.observer.observe(this.container);
    } else {
      this.load();
    }
 
    return this;
  }
 
  async load() {
    try {
      this.module = await this.loader();
      const Component = this.module.default || this.module;
 
      this.container.innerHTML = "";
 
      if (typeof Component === "function") {
        const rendered = Component(this.container);
        if (rendered instanceof HTMLElement) {
          this.container.appendChild(rendered);
        }
      }
 
      this.container.classList.add("lazy-loaded");
    } catch (error) {
      this.container.innerHTML = `
        <div class="lazy-error">
          <p>Failed to load component</p>
          <button class="retry-btn">Retry</button>
        </div>
      `;
      this.container.querySelector(".retry-btn").addEventListener(
        "click",
        () => this.load()
      );
    }
  }
}
 
// Usage
new LazyComponent("#chart-area", () => import("./components/Chart")).init();
new LazyComponent("#comments", () => import("./components/Comments"), {
  loadOnVisible: true,
}).init();

Progressive Data Loading

javascriptjavascript
class ProgressiveLoader {
  constructor(options = {}) {
    this.batchSize = options.batchSize || 20;
    this.threshold = options.threshold || 0.8;
    this.loading = false;
    this.hasMore = true;
    this.offset = 0;
    this.items = [];
  }
 
  async fetchBatch(url) {
    if (this.loading || !this.hasMore) return [];
 
    this.loading = true;
 
    try {
      const separator = url.includes("?") ? "&" : "?";
      const response = await fetch(
        `${url}${separator}offset=${this.offset}&limit=${this.batchSize}`
      );
      const data = await response.json();
 
      if (data.items.length < this.batchSize) {
        this.hasMore = false;
      }
 
      this.offset += data.items.length;
      this.items.push(...data.items);
 
      return data.items;
    } finally {
      this.loading = false;
    }
  }
 
  observeScrollEnd(container, url, renderItem) {
    const sentinel = document.createElement("div");
    sentinel.className = "scroll-sentinel";
    container.appendChild(sentinel);
 
    const observer = new IntersectionObserver(
      async ([entry]) => {
        if (entry.isIntersecting && this.hasMore) {
          const newItems = await this.fetchBatch(url);
          newItems.forEach((item) => {
            const el = renderItem(item);
            container.insertBefore(el, sentinel);
          });
 
          if (!this.hasMore) {
            observer.disconnect();
            sentinel.remove();
          }
        }
      },
      { rootMargin: "400px" }
    );
 
    observer.observe(sentinel);
    return observer;
  }
}
 
// Usage
const loader = new ProgressiveLoader({ batchSize: 25 });
const container = document.getElementById("feed");
 
loader.observeScrollEnd(container, "/api/posts", (post) => {
  const el = document.createElement("article");
  el.innerHTML = `<h3>${post.title}</h3><p>${post.excerpt}</p>`;
  return el;
});

Script Lazy Loading

javascriptjavascript
class ScriptLoader {
  constructor() {
    this.loaded = new Map();
    this.loading = new Map();
  }
 
  load(src, options = {}) {
    if (this.loaded.has(src)) {
      return Promise.resolve(this.loaded.get(src));
    }
 
    if (this.loading.has(src)) {
      return this.loading.get(src);
    }
 
    const promise = new Promise((resolve, reject) => {
      const script = document.createElement("script");
      script.src = src;
      script.async = options.async !== false;
 
      if (options.integrity) {
        script.integrity = options.integrity;
        script.crossOrigin = "anonymous";
      }
 
      script.onload = () => {
        this.loaded.set(src, true);
        this.loading.delete(src);
        resolve(true);
      };
 
      script.onerror = () => {
        this.loading.delete(src);
        reject(new Error(`Failed to load script: ${src}`));
      };
 
      document.head.appendChild(script);
    });
 
    this.loading.set(src, promise);
    return promise;
  }
 
  async loadWithDeps(scripts) {
    // Load scripts in dependency order
    for (const script of scripts) {
      await this.load(script.src, script.options);
    }
  }
 
  async loadParallel(sources) {
    return Promise.all(sources.map((src) => this.load(src)));
  }
}
 
// Usage
const scripts = new ScriptLoader();
 
// Load third-party libraries on demand
async function initMap() {
  await scripts.load("https://maps.googleapis.com/maps/api/js?key=KEY");
  const map = new google.maps.Map(document.getElementById("map"), {
    center: { lat: 40.7, lng: -74.0 },
    zoom: 12,
  });
}
 
document.getElementById("show-map").addEventListener("click", initMap, {
  once: true,
});
PatternTriggerBest ForSavings
Image lazy loadingViewport proximityMedia-heavy pages40-70% bandwidth
Component lazy loadingVisibility or interactionComplex widgets30-60% JS size
Route lazy loadingNavigationSPAs60-80% initial bundle
Data lazy loadingScroll positionLong lists/feedsFaster initial render
Script lazy loadingUser actionThird-party libsRemoves blocking scripts
Rune AI

Rune AI

Key Insights

  • IntersectionObserver is the most flexible lazy loading API: It supports custom root margins, thresholds, and works with any DOM element including images, iframes, and component containers
  • Native loading="lazy" requires zero JavaScript: Use it for images and iframes as the default, with IntersectionObserver only for custom behaviors
  • Never lazy load above-the-fold content: Hero images and critical content should load eagerly with fetchpriority="high" to optimize Largest Contentful Paint
  • Progressive data loading reduces initial render time: Load only the first batch of data and fetch more as the user scrolls, using a sentinel element observed by IntersectionObserver
  • Component lazy loading with error boundaries provides resilience: Wrap lazy component loading in try/catch with retry buttons so users can recover from network failures
RunePowered by Rune AI

Frequently Asked Questions

Should I use native loading="lazy" or IntersectionObserver?

Use native `loading="lazy"` as the primary approach since it requires no JavaScript and is supported in all modern browsers. Use IntersectionObserver as a progressive enhancement for custom behaviors like placeholder animation, blur-up effects, or when you need precise control over the root margin and loading threshold.

What rootMargin should I use for IntersectionObserver lazy loading?

Start with `"200px 0px"` for images and `"100px"` for components. This loads resources 200px before they enter the viewport, giving enough time to download on typical connections. Increase to `"400px"` for heavy resources or slow connections. For data-saver mode, reduce to `"50px"` to minimize unnecessary loads.

How do I handle lazy loading with SEO?

Search engines can see `data-src` attributes and often follow them, but for critical SEO content, use native `loading="lazy"` with real `src` attributes. Above-the-fold images should always use `loading="eager"` or omit the attribute entirely. Content text should never be lazily loaded since crawlers may not trigger scroll events.

Can lazy loading hurt Largest Contentful Paint (LCP)?

Yes, if misapplied. Never lazy load the hero image or above-the-fold content. The LCP element should load eagerly with `fetchpriority="high"`. Only lazy load images and components below the fold. Use the viewport height check (`getBoundingClientRect().top < window.innerHeight`) to determine which images are above the fold.

How do I lazy load CSS along with components?

Inject a `<link>` tag when the component loads. Create a helper that returns a Promise resolving when the stylesheet finishes loading. Import the CSS module alongside the component module using dynamic import. Bundlers like webpack can split CSS per chunk automatically when using `mini-css-extract-plugin`.

Conclusion

Lazy loading reduces initial page weight by deferring non-critical resources. IntersectionObserver provides precise viewport-based triggers, while native loading="lazy" handles images with zero JavaScript. Component and script lazy loading eliminate heavy framework and library code from the initial bundle. For image-specific optimizations, see How to Lazy Load Images and Components in JS. For infinite scrolling built on lazy data loading, see Implementing Infinite Scroll with JS Observers.