Skip to content
yutils

How Lazy Loading Actually Works

Code splitting (route-based + dynamic import), the IntersectionObserver API for images, prefetch vs preload vs lazy, why over-splitting hurts (TCP round-trips), and the patterns React Suspense / Next dynamic actually use.

~9 min read

SPA's initial bundle is 5 MB → first paint takes 5s+. Yet users only need the first page. Load the rest on demand — lazy loading. This guide covers code splitting (route + dynamic), image IntersectionObserver, prefetch vs preload, and the over-splitting trap.

Code Splitting — Smaller First Chunk

Initial (single bundle):
  /bundle.js  3.0 MB  → one shot: download + parse + execute

Route-based splitting:
  /home.js     0.3 MB
  /dashboard.js 0.8 MB
  /settings.js  0.2 MB
  /admin.js     1.7 MB
  /vendor.js    0.5 MB (shared libs)

→ On home page entry, load only home.js + vendor.js (0.8 MB)
→ Other routes lazy-load on click

JS syntax (supported by all modern bundlers):
  // static import
  import HomePage from "./HomePage";

  // dynamic import (separate chunk + async)
  const HomePage = lazy(() => import("./HomePage"));

React Suspense + lazy

import {lazy, Suspense} from "react";

const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

→ Entering /dashboard:
  1. Suspense renders <Spinner /> (lazy throws a promise)
  2. Dynamic import downloads the dashboard chunk (~100-500ms)
  3. After resolve, Dashboard renders

Next.js dynamic uses the same pattern:
  const Map = dynamic(() => import("./Map"), { ssr: false });

Image Lazy Loading — IntersectionObserver

Page with 100 images. Loading all up front = heavy and slow.

Old: scroll handler
  window.addEventListener("scroll", () => {
    images.forEach(img => {
      if (isInViewport(img)) loadImage(img);
    });
  });
  → Computes for every scroll event = expensive (jank)

Modern: IntersectionObserver (native)
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        observer.unobserve(img);
      }
    });
  }, { rootMargin: "200px" });   // start loading 200px early

  document.querySelectorAll("img[data-src]").forEach(img => observer.observe(img));

→ Browser tracks viewport, app has zero scroll handler.
→ rootMargin gives a prefetch margin (e.g. 200px before visible).

Native HTML option (modern browsers):
  <img src="..." loading="lazy" />
  → Same IntersectionObserver behavior, built into the browser.

Prefetch vs Preload vs Lazy

DirectiveIntentExample
<link rel="preload">"Needed soon on this page" → download now (high priority)Hero image / font on the current page
<link rel="prefetch">"Might be needed on the next page" → idle download (low priority)Next page's chunk
<link rel="dns-prefetch">"Will hit this domain soon" → DNS onlyAnalytics / CDN domain
<link rel="preconnect">"Will use this domain soon" → DNS + TCP + TLS in advanceAPI endpoint
lazy (loading="lazy")"Load only when needed" — opposite directionOff-screen images

Next.js / Nuxt routers auto-prefetch on hover or near-link mouse — modern framework defaults.

Over-Splitting Pitfalls

Too many chunks = many TCP round-trips
  1 bundle (3 MB): 1 connection → 3 MB stream
  30 chunks (100 KB each): 30 connections (or HTTP/2 multiplex)
                          → cumulative download + parse

Trade-off:
- HTTP/1.1: high per-connection cost → fewer chunks is faster
- HTTP/2 + multiplex: multiple chunks fine
- HTTP/3 + 0-RTT: more freedom

Recommendations:
- Not too small (min 30-50 KB per chunk)
- Not too large (each < 200-500 KB)
- Vendor chunk + per-route + big-component splits

Modern Frontend Lazy Patterns

React + Next.js:
1. App Router route-level code split (automatic)
2. dynamic(() => import(...)) for component lazy
3. next/image with loading="lazy" + responsive
4. Suspense + Server Component streaming (RSC)

Vue + Nuxt:
1. defineAsyncComponent + Suspense
2. <NuxtImg> auto lazy + WebP / AVIF conversion
3. Per-route chunks automatic

Vite:
- dev: native ESM (lazy by default, on-demand transform)
- prod: rollup-based production split

→ Modern toolchains' defaults are already good. Go deeper only after measuring.

Common Pitfalls

  • Everything lazy — components in the first paint shouldn't be lazy (spinner-only first render). Above-the-fold stays static-imported.
  • Big chunks in home bundle — admin-only libs (charts, editor) leaking into home. Audit with webpack-bundle-analyzer.
  • preload overuse — everything preloaded = nothing high-priority. Reserve for critical resources.
  • rootMargin 0 in IntersectionObserver — start load only when in viewport → user waits a beat. Use rootMargin 100-300px.
  • Cost of route prefetch — prefetching every link costs data (mobile). Hover-prefetch or visible-link only.

Wrap-up

Lazy loading — "download when needed". Smaller initial bundle = faster first paint. But too-small chunks add round-trip overhead.

Practical — leverage modern framework defaults (Next.js / Nuxt route split + dynamic + image lazy). Monitor LCP / TTI / total bundle size. Explicit dynamic only for big components (charts, editors, maps). Prefetch on hover or visible-link only.

Back to guides