Skip to content
fearchitect
Performance Engineering

Bundle Architecture & Code Splitting

Ship only the JS a route needs, cache the rest long-term.

By Abas TurabliReviewed

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 angle

Code 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:

  1. Route-based: the default in Next.js — each page gets its own chunk.
  2. Component-based: React.lazy / next/dynamic defers a heavy widget's chunk until that component first mounts.
  3. Vendor chunking: isolating node_modules into 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".

Client component wrapper using next/dynamic with ssr: falsetsx
// 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.

Route chunk loads immediately; the HeavyChart chunk fetches only on mount; the vendor chunk is served from CDN cache because its hash is stable.

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.

Further reading

Search fearchitect

Jump to a topic, mode, or action.