Summary
Code splitting breaks one large bundle into smaller chunks loaded on demand. Route-based splitting is the minimum viable default: users downloading `/settings` never pay for `/dashboard` code. Component-level splitting with `React.lazy` or `next/dynamic` defers heavy widgets until they are needed. Deterministic chunk IDs make CDN caches survive deploys.
Jump to the interview angleCode splitting
Without splitting, the browser parses your whole bundle before anything renders — including modules only used on routes the user never visits.
Code splitting divides the bundle at defined boundaries so each piece loads on demand. Three levels matter:
- Route-based: the default in Next.js — each page gets its own chunk.
- Component-based:
React.lazy/next/dynamicdefers a heavy widget's chunk until that component first mounts. - Vendor chunking: isolating
node_modulesinto a separate chunk lets it cache independently of app code churn.
Modern bundlers (Webpack, Turbopack, Rolldown) handle graph traversal; your job is picking boundaries and verifying output with a bundle analyzer.
How the bundler produces chunks
- Dynamic `import()` calls are the cut points — each produces a new content-hashed chunk file.
- Deterministic chunk IDs: set `optimization.moduleIds: 'deterministic'` in Webpack (production default); Turbopack uses `turbopackModuleIds: 'deterministic'`.
- Tree-shaking removes dead exports at build time — requires ESM and `"sideEffects": false` in the library's `package.json`.
- Vendor splitting separates `node_modules` from app code so CDN cache survives app-only changes.
- Verify output with `@next/bundle-analyzer` — look for duplicated React, unexpected chunk sizes, and barrel-file bloat.
Deferring a heavy widget with next/dynamic
Use next/dynamic inside a Client Component to defer a heavy widget's chunk. ssr: false skips server rendering entirely — it is only valid inside a Client Component marked with "use client".
// app/dashboard/DashboardClient.tsx — Next.js 16 / React 19
"use client";
import dynamic from "next/dynamic";
// Deferred: HeavyChart is not needed on first paint.
// Its chunk loads only when the component mounts.
const HeavyChart = dynamic(
() => import("@/components/HeavyChart"),
{
loading: () => <ChartSkeleton />,
ssr: false, // ssr: false is only valid inside Client Components
}
);
export default function DashboardClient() {
return (
<main>
<SummaryCards /> {/* in the route chunk — always needed */}
<HeavyChart /> {/* in its own chunk — deferred */}
</main>
);
}ssr: false is only valid in Client Components. The "use client" directive makes this file a Client Component; the Server Component page imports it.
Over-splitting backfires
Too many small chunks cause waterfall requests that exceed HTTP/2 multiplexing gains. Splitting a component under ~5 kB gzipped usually costs more in round-trips than it saves in parse time. Never split a component needed on first paint above the fold.
Tradeoffs
Pros
- Initial parse time drops when only the current-route chunk loads.
- Vendor chunks cache across deploys if their content hash is stable.
- Large, rarely-used widgets load only when rendered — no upfront cost.
- Smaller chunks can load in parallel over HTTP/2, reducing total time.
Cons
- Too many small chunks cause waterfall requests that exceed HTTP/2 multiplexing gains.
- Dynamic imports add a network round-trip on first render, causing visible loading states.
- Misconfigured splits can duplicate React itself across chunks.
- Tree-shaking fails silently on CommonJS imports, so chunk sizes mislead without analysis.
Interview angle
Anchor on the trade-off triangle: payload size, cache lifetime, and waterfall risk. Name the concrete knobs — next/dynamic, deterministic chunk IDs, SplitChunksPlugin — and say when you'd check a bundle analyzer.
Soundbite: "Split at route boundaries by default, defer heavy widgets with next/dynamic, lock chunk IDs to deterministic hashes so CDN caches survive deploys."
Key terms
- Dynamic import
- A native `import()` call that tells the bundler to cut a new chunk loaded on demand.
- Tree-shaking
- Dead-code elimination at build time; works on ESM static imports, not CommonJS `require`.
- Deterministic chunk ID
- A hash derived from module content/path so a chunk's filename is stable across deploys.
- SplitChunksPlugin
- Webpack plugin that extracts shared modules into separate chunks based on size and reuse.
- Vendor chunk
- A chunk containing only `node_modules` code, cached independently from fast-changing app code.