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
| Directive | Intent | Example |
|---|---|---|
<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 only | Analytics / CDN domain |
<link rel="preconnect"> | "Will use this domain soon" → DNS + TCP + TLS in advance | API endpoint |
| lazy (loading="lazy") | "Load only when needed" — opposite direction | Off-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 splitsModern 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.