JavaScript Interview Prep
Performance & Optimization

Lazy Loading

Ship Only What the User Is About to See

LinkedIn Hook

Your landing page is 2.4MB of JavaScript. The user lands, bounces after 3 seconds, and 2.3MB of that bundle was never executed.

You paid for the download. The user paid for the download. Nobody benefited.

Lazy loading flips that math: load what's visible right now, defer everything else until the user actually asks for it. Images load as they scroll into view. Route bundles download when the user clicks the link. Heavy components appear with a Suspense fallback instead of blocking the paint.

In this lesson: native loading="lazy", a from-scratch IntersectionObserver image loader, dynamic import() for routes, React.lazy + Suspense, hover-based preloading — and the one mistake that makes lazy loading hurt instead of help (lazy loading above-the-fold content).

If your Largest Contentful Paint is over 2.5s — this lesson is the fix.

Read the full lesson -> [link]

#JavaScript #WebPerformance #LazyLoading #IntersectionObserver #CodeSplitting #CoreWebVitals #Frontend


Lazy Loading thumbnail


What You'll Learn

  • How to lazy load images with the native loading="lazy" attribute and with IntersectionObserver from scratch
  • How dynamic import() enables route-based code splitting and React.lazy + Suspense
  • How to preload the next likely chunk on hover/focus — and when lazy loading actually hurts performance

The Library Analogy

Imagine a library with a million books. You don't bring all million books to the reading desk when you walk in — you only fetch the one you need, when you need it. Lazy loading applies the same principle to web resources: don't load what the user hasn't asked for yet.

Native Lazy Loading for Images

// HTML attribute — simplest approach (Chrome 76+, widely supported)
// <img src="photo.jpg" loading="lazy" alt="Description">
// <iframe src="embed.html" loading="lazy"></iframe>

// Three values:
// loading="lazy"  — defer until near viewport
// loading="eager" — load immediately (default)
// loading="auto"  — browser decides

// IMPORTANT: Always set width and height to prevent layout shift
// <img src="photo.jpg" loading="lazy" width="800" height="600" alt="Photo">

Lazy Loading Images with IntersectionObserver (From Scratch)

class LazyImageLoader {
  constructor(options = {}) {
    this.options = {
      rootMargin: options.rootMargin || "200px 0px", // load 200px before visible
      threshold: options.threshold || 0,
      placeholder: options.placeholder || "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
    };

    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      {
        rootMargin: this.options.rootMargin,
        threshold: this.options.threshold
      }
    );

    this.loadedCount = 0;
    this.totalCount = 0;
  }

  handleIntersection(entries) {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;

      const img = entry.target;
      this.loadImage(img);
      this.observer.unobserve(img); // stop watching after load
    });
  }

  loadImage(img) {
    const src = img.dataset.src;
    const srcset = img.dataset.srcset;

    if (!src) return;

    // Create a temporary image to preload
    const tempImg = new Image();

    tempImg.onload = () => {
      img.src = src;
      if (srcset) img.srcset = srcset;
      img.classList.remove("lazy");
      img.classList.add("loaded");
      this.loadedCount++;
      img.dispatchEvent(new CustomEvent("lazyloaded"));
    };

    tempImg.onerror = () => {
      img.classList.add("lazy-error");
      console.warn(`Failed to lazy load: ${src}`);
    };

    tempImg.src = src; // triggers the actual download
  }

  observe(selector = "img[data-src]") {
    const images = document.querySelectorAll(selector);
    this.totalCount = images.length;
    images.forEach(img => this.observer.observe(img));
    return this; // chainable
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const loader = new LazyImageLoader({ rootMargin: "300px 0px" });
loader.observe();

// HTML structure:
// <img data-src="real-photo.jpg"
//      data-srcset="photo-400.jpg 400w, photo-800.jpg 800w"
//      src="placeholder.jpg"
//      class="lazy"
//      alt="Description">

Route-Based Lazy Loading (Dynamic Import)

// STATIC import — bundled into main chunk, loaded upfront
import { Dashboard } from "./pages/Dashboard";
import { Settings } from "./pages/Settings";
import { Analytics } from "./pages/Analytics";

// DYNAMIC import — separate chunks, loaded on demand
async function loadPage(pageName) {
  switch (pageName) {
    case "dashboard":
      const { Dashboard } = await import("./pages/Dashboard");
      return Dashboard;
    case "settings":
      const { Settings } = await import("./pages/Settings");
      return Settings;
    case "analytics":
      const { Analytics } = await import("./pages/Analytics");
      return Analytics;
  }
}

// Real-world router example
class Router {
  constructor() {
    this.routes = {
      "/": () => import("./pages/Home"),
      "/dashboard": () => import("./pages/Dashboard"),
      "/settings": () => import("./pages/Settings"),
      "/analytics": () => import("./pages/Analytics")
    };
  }

  async navigate(path) {
    const loader = this.routes[path];
    if (!loader) {
      const { NotFound } = await import("./pages/NotFound");
      return NotFound;
    }

    const module = await loader();
    return module.default; // the page component
  }
}

Component Lazy Loading (React Pattern)

// React.lazy + Suspense
import React, { lazy, Suspense } from "react";

// Instead of: import HeavyChart from "./HeavyChart";
const HeavyChart = lazy(() => import("./HeavyChart"));
const DataTable = lazy(() => import("./DataTable"));

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      <Suspense fallback={<div>Loading chart...</div>}>
        <HeavyChart data={chartData} />
      </Suspense>

      <Suspense fallback={<Skeleton rows={10} />}>
        <DataTable />
      </Suspense>
    </div>
  );
}

// Named export lazy loading (React.lazy requires default export)
const MyComponent = lazy(() =>
  import("./MyComponent").then(module => ({
    default: module.NamedExport
  }))
);

When Lazy Loading Hurts

// DON'T lazy load above-the-fold content
// BAD — user sees a loading spinner for the hero section
const HeroSection = lazy(() => import("./HeroSection"));

// GOOD — eagerly load what users see first
import HeroSection from "./HeroSection";
// Lazy load what's below the fold
const Comments = lazy(() => import("./Comments"));
const RelatedPosts = lazy(() => import("./RelatedPosts"));

// Preload hints — tell the browser to start loading early
// <link rel="preload" href="/critical-image.jpg" as="image">
// <link rel="prefetch" href="/next-page-bundle.js"> <!-- for likely next nav -->

// Programmatic preload on hover/focus
function preloadOnHover(linkElement, importFn) {
  let preloaded = false;

  linkElement.addEventListener("mouseenter", () => {
    if (!preloaded) {
      importFn(); // starts loading the chunk
      preloaded = true;
    }
  });
}

// Usage: preload settings page when user hovers the nav link
preloadOnHover(
  document.getElementById("settings-link"),
  () => import("./pages/Settings")
);

Lazy Loading visual 1


Common Mistakes

  • Lazy loading above-the-fold content (hero image, main headline, primary CTA) — it delays the Largest Contentful Paint and makes the page feel slower, not faster.
  • Forgetting width and height on lazy images — the browser doesn't reserve space, so Cumulative Layout Shift (CLS) skyrockets when images finally load.
  • Using React.lazy with a named export directly — React.lazy requires a default export. Wrap named exports with .then(m => ({ default: m.Named })) or switch to a default export.

Interview Questions

Q: What is lazy loading and what are its benefits?

Lazy loading defers the loading of resources until they're actually needed. Benefits: 1) Faster initial page load — less JavaScript/images to download upfront. 2) Reduced bandwidth — users who don't scroll down never download below-fold resources. 3) Lower memory usage — fewer DOM nodes and assets in memory. 4) Better Core Web Vitals — improved LCP and FID scores.

Q: How does IntersectionObserver-based lazy loading work?

You store the real image URL in a data-src attribute and use a placeholder as the initial src. An IntersectionObserver watches each image. When the observer detects the image entering (or approaching) the viewport, it copies data-src to src, triggering the browser to download the real image. After loading, you unobserve the image to stop tracking it.

Q: When should you NOT use lazy loading?

Don't lazy load above-the-fold content (hero images, primary heading, critical CSS) — it adds unnecessary delay to what users see first, hurting LCP. Don't lazy load small resources where the overhead of the observer + dynamic loading exceeds the resource size. Don't lazy load critical functionality that users need immediately.

Q: What is import() and how does it enable code splitting?

import() is the dynamic import expression. Unlike the static import ... from ... at the top of a file, import("./Page") is a function call that returns a Promise resolving to the module's exports. Bundlers (Webpack, Vite, Rollup) detect each import() call and emit a separate chunk for its target, so the browser only downloads that chunk when the Promise is created — enabling route-level or interaction-level code splitting.

Q: Name three things you can lazy load in a typical web app.

  1. Images and iframes (via loading="lazy" or IntersectionObserver + data-src). 2) Route bundles (via dynamic import() in your router). 3) Heavy components (via React.lazy + Suspense, or equivalent patterns in Vue/Svelte). Bonus: fonts (font-display: swap), videos (native preload="none"), and third-party widgets (iframe facades).

Quick Reference — Cheat Sheet

LAZY LOADING — WHAT, HOW, WHEN NOT TO

NATIVE (HTML)
  <img loading="lazy" width="W" height="H">
  <iframe loading="lazy">
  values: lazy | eager | auto

INTERSECTION OBSERVER (IMAGES)
  1. <img data-src="real.jpg" src="placeholder">
  2. new IntersectionObserver(cb, { rootMargin: "200px 0px" })
  3. On intersect -> copy data-src to src, then unobserve

CODE SPLITTING (DYNAMIC IMPORT)
  const mod = await import("./Page")     // one chunk per call
  React.lazy(() => import("./Comp"))     // + <Suspense fallback>

PRELOAD / PREFETCH
  <link rel="preload"  href="hero.jpg" as="image">   // needed now
  <link rel="prefetch" href="next.js">               // next nav
  hover listener -> importFn()                        // warm on intent

DO NOT LAZY LOAD
  - Above-the-fold hero image (hurts LCP)
  - Critical inline scripts
  - Tiny assets where overhead > payload

Previous: Memory Leaks -> The Silent Killer Next: Web Workers -> Parallel JavaScript Without Freezing the UI


This is Lesson 13.2 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.

On this page