Skip to content
Back to Quality Assurance

Lazy Loading

Defer loading of below-the-fold content, images, and heavy components until the user needs them.

Why

A page that loads everything upfront loads 3MB of images, a 200KB chart library, and a comments section that 80% of users never scroll to. Lazy loading flips this: the initial load includes only what is visible (the hero, the first article), and the rest loads as the user scrolls or interacts. This cuts the initial payload by 50-80%, which directly improves LCP and time-to-interactive. The browser's native loading="lazy" attribute makes image lazy loading a one-line change, and React.lazy / dynamic import() makes component lazy loading trivial. The payoff is immediate — the page loads in 1s instead of 3s, and the user who never scrolls to the comments never pays the cost of loading them.

When

For all below-the-fold images, for heavy components not needed on first paint (charts, editors, maps, video players), and for route-level code splitting. Not for above-the-fold content or the LCP element.

How

1. Lazy-load images with the native loading="lazy" attribute
   - <img src="photo.jpg" loading="lazy" width="800" height="600" />
   - Always include width and height to prevent layout shift (CLS)
   - Set fetchpriority="high" on the LCP image to prioritize it
   - Use a blurred placeholder (blur-up) for a better perceived experience

2. Lazy-load components with React.lazy and Suspense
   - const Chart = lazy(() => import("./HeavyChart"));
   - Wrap in <Suspense fallback={<Skeleton />}> to show a placeholder while loading
   - Load on interaction (click) or on scroll into view (IntersectionObserver), not on mount

3. Lazy-load routes with dynamic imports
   - Each route is a separate chunk, loaded only when navigated to
   - Prefetch the next likely route on idle (requestIdleCallback) for instant navigation
   - Use the framework's built-in code splitting (Next.js dynamic, Vite manualChunks)

4. Lazy-load data with IntersectionObserver
   - Do not fetch comments until the comments section is scrolled into view
   - const observer = new IntersectionObserver(callback); observer.observe(el);
   - Load the next page of a list as the user approaches the bottom (infinite scroll)

5. Measure the impact
   - Compare LCP and total blocked time before and after lazy loading
   - Check the Network tab: the initial load should have fewer and smaller requests
   - Verify that scrolling triggers the lazy loads (not a timer that loads everything anyway)

Example

// Lazy images: native loading="lazy"
<img src="hero.jpg" fetchpriority="high" width="1200" height="600" alt="Hero" />
<img src="article-1.jpg" loading="lazy" width="800" height="500" alt="Article 1" />
<img src="article-2.jpg" loading="lazy" width="800" height="500" alt="Article 2" />

// Lazy component: load the chart only when the section is visible
import { lazy, Suspense, useState, useEffect, useRef } from "react";
const HeavyChart = lazy(() => import("./HeavyChart"));

function ChartSection() {
  const [visible, setVisible] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) setVisible(true);
    }, { rootMargin: "100px" }); // start loading 100px before visible
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={ref}>
      {visible && (
        <Suspense fallback={<ChartSkeleton />}>
          <HeavyChart data={data} />
        </Suspense>
      )}
    </div>
  );
}

// Lazy route: prefetch on idle for instant navigation
const Settings = lazy(() => import("./routes/Settings"));
// Prefetch when the user is likely to navigate (e.g., hover on the link)
function SettingsLink() {
  return (
    <Link
      to="/settings"
      onMouseEnter={() => requestIdleCallback(() => import("./routes/Settings"))}
    >
      Settings
    </Link>
  );
}

Common Problems

  • ⚠Lazy-loading the LCP element (the hero image) delays the most important content — the LCP should be eagerly loaded with fetchpriority="high", not lazy.
  • ⚠Lazy-loading without a placeholder (no Suspense fallback, no skeleton) causes a blank flash or a layout jump when the content loads, increasing CLS and degrading the experience.
  • ⚠Using a timer to trigger lazy loading (setTimeout 3s then load everything) defeats the purpose — the user waits 3s and then everything loads at once, same as eager loading but with an extra delay.
  • ⚠Lazy-loading images without width/height attributes causes layout shift when they load — the content below jumps down, CLS spikes, and the user mis-clicks.

Best Practices

  • ✓Eagerly load the LCP element (hero image, main heading) with fetchpriority="high" and lazy-load everything below the fold — the LCP is what the user sees first, and lazy-loading it defeats the purpose.
  • ✓Always provide a Suspense fallback or skeleton that reserves the same space as the lazy content — this prevents layout shift and gives the user immediate feedback that content is loading.
  • ✓Use IntersectionObserver with a rootMargin (e.g., 200px) to start loading before the element is fully visible — this makes the lazy load feel instant, as the content is ready by the time the user scrolls to it.
  • ✓Prefetch the next likely route on idle (requestIdleCallback during link hover) so navigation feels instant — the chunk is already in the browser cache when the user clicks.