Back to Performance
Performance
medium
mid

What is lazy loading and how would you implement it?

Lazy loading defers loading a resource until it's actually needed — usually 'about to be visible.' For images: <img loading='lazy'>. For iframes: <iframe loading='lazy'>. For JS: dynamic import() + React.lazy/Suspense. For arbitrary components: IntersectionObserver to mount when the placeholder enters the viewport. Reduces initial bytes, JS parse cost, and main-thread work; trade-off is the loading state when it finally needs to appear.

7 min read·~5 min to think through

Lazy loading = don't load something until it's needed. The trigger is usually scroll-into-view, hover, or click. Goal: cut initial bytes and main-thread work; pay the cost only for resources users actually engage with.

1. Native image lazy-loading

html
<img src="below-fold.jpg" loading="lazy" alt="" width="800" height="600">
  • Zero JS. Browser decides when to fetch based on viewport distance.
  • Set width + height (or use CSS aspect-ratio) so the page doesn't shift when the image loads.
  • Don't lazy-load the LCP image — that's a known anti-pattern that tanks LCP. Use loading="eager" (default) + fetchpriority="high" for it.

2. Native iframe lazy-loading

html
<iframe src="https://www.youtube.com/embed/…" loading="lazy"></iframe>

YouTube embeds, ads, maps in the sidebar — huge wins because iframes can load their own megabytes of JS.

3. JS code splitting (lazy modules)

js
button.onclick = async () => {
  const { default: heavy } = await import('./heavy-tool.js');
  heavy.run();
};

The bundler emits heavy-tool.js as a separate chunk fetched on demand.

4. React.lazy + Suspense

jsx
const Modal = React.lazy(() => import('./Modal'));

function App() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Open</button>
      {open && (
        <Suspense fallback={<Spinner />}>
          <Modal />
        </Suspense>
      )}
    </>
  );
}

React.lazy works on default exports. For named exports, use lazy(() => import('./X').then(m => ({ default: m.Foo }))).

In Next.js, prefer next/dynamic — supports SSR control:

jsx
import dynamic from 'next/dynamic';
const Map = dynamic(() => import('./Map'), { ssr: false, loading: () => <Spinner /> });

5. IntersectionObserver for arbitrary lazy components

For components without code-split needs but heavy render cost:

jsx
function LazyVisible({ children, height = 200 }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) {
        setVisible(true);
        io.disconnect();
      }
    }, { rootMargin: '200px' });
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);

  return <div ref={ref} style={{ minHeight: height }}>{visible ? children : null}</div>;
}

rootMargin: '200px' starts loading before it's strictly visible so it's ready by the time the user reaches it.

6. Lazy data fetching

React Query / SWR's useQuery only runs when the component mounts. Wrap heavy data needs in a deferred component, and the fetch deferral comes for free.

When NOT to lazy-load

  • LCP image / hero font — those are critical.
  • Above-the-fold content — visible immediately, no benefit.
  • Tiny components — overhead of split + suspense exceeds gain.
  • Sequential dependencies that would create waterfalls (lazy → load → lazy → load).

Layout shift trap

Lazy-loaded images without reserved dimensions cause CLS (cumulative layout shift) when they arrive. Always:

  • Set explicit width and height attributes, or
  • Use aspect-ratio CSS, or
  • Provide a sized skeleton placeholder.

Measurement

  • Lighthouse: "Defer offscreen images," "Reduce unused JavaScript."
  • DevTools Coverage tab: see how much JS was actually executed vs loaded.
  • Real metrics: LCP, INP, total bytes transferred.

Mental model

Lazy loading is the opposite of preload. Preload: "I need this earlier than the browser would discover it." Lazy: "I don't need this yet — wait." Use both in concert: preload the LCP image and the critical font; lazy-load everything below the fold and behind interactions.

Follow-up questions

  • Why is lazy-loading the LCP image bad?
  • How do you prevent layout shift when images lazy-load?
  • What's the difference between React.lazy and next/dynamic?
  • When does IntersectionObserver-based lazy loading make sense over native?

Common mistakes

  • Lazy-loading the LCP image — tanks the LCP score.
  • No width/height/aspect-ratio on lazy images — causes CLS.
  • Splitting tiny components — overhead > payload.
  • Lazy-loading critical interactions — UI feels stuck.
  • Forgetting to disconnect IntersectionObserver — memory leak on long-lived pages.
  • Nesting Suspense too deep — many tiny loaders flash on screen during navigation.

Performance considerations

  • Lazy loading is one of the cheapest, highest-ROI optimizations: a few lines of code, zero runtime overhead, often 30–60% smaller initial transfer on content-heavy pages. Pair with code splitting for compound wins. The only counter-rule: never lazy the LCP element.

Edge cases

  • Print rendering forces all lazy images to load — browsers handle this.
  • Save-Data signals — opt out of speculative prefetches.
  • Anchor navigation to #section deep on the page — browser forces lazy images above the anchor to load.
  • Lazy iframes don't fire onload until they're actually loaded — UX states tied to onload need adjustment.
  • SSR: lazy components render their fallback during SSR; client hydrates and loads — keep fallback visually stable to avoid hydration mismatch.

Real-world examples

  • Most major news sites use native loading='lazy' on article images.
  • YouTube embed lite (lite-youtube-embed) lazy-loads the iframe and player JS.
  • Pinterest's masonry grid uses IntersectionObserver-based lazy loading aggressively.
  • React docs use React.lazy + Suspense for the code playground.

Senior engineer discussion

Seniors should split this by resource type — images, iframes, code, data — and pick the right primitive per type. They protect the LCP element from accidental laziness, reserve space to avoid CLS, and treat lazy loading as a default for everything below the fold. They also recognize when lazy loading is fighting the framework (e.g., RSCs already skip JS, lazy on top adds noise).

Related questions