Bundling & Code-Splitting

The discipline of shipping less JavaScript: split the bundle so users download only what a page needs, tree-shake away the dead code, pick a bundler for the right reasons — and govern it across many teams.

1The goal: ship less JavaScript

Every other byte (HTML, CSS, images) is cheaper than JS. A JS byte gets downloaded, parsed, compiled, and executed on the main thread — and that execution is what blocks INP and delays interactivity (Lessons 02–03). So the whole game is: send the least JS that makes this page work, and no more. Three tools, in order of impact:

  1. Code-splitting — break one giant bundle into chunks loaded on demand, so a route only pays for its own code.
  2. Tree-shaking — statically drop code you import but never use (dead-code elimination).
  3. Pruning & swapping — kill duplicate deps, replace heavy libraries, analyze what's actually in there — then guard it in CI so it can't creep back.
One-liner

“JS is the most expensive byte — it's parsed and run on the main thread. So I optimize for what we ship, not just what we cache: split by route, tree-shake the rest, and put a size budget in CI.”

2Code-splitting — route, component, vendor

A bundler creates a new chunk at every dynamic import() — a “split point.” That chunk is fetched only when the code path runs. The static import at the top of a file always ends up in the initial bundle; import() is the lever. [MDN]

// static — always in the initial bundle
import { formatPrice } from './utils';

// dynamic — its own chunk, fetched only when this runs
button.addEventListener('click', async () => {
  const { openMap } = await import('./heavy-map-widget');
  openMap();
});

Route-level split

do this first — biggest win

Each route loads its own chunk. A user on the search page never downloads the checkout flow's code. In React: React.lazy(() => import('./Checkout')) behind a <Suspense> fallback; frameworks (Next/Remix) do it per-route automatically.

Component-level split

heavy + conditional/below-fold

Lazy-load the expensive, not-immediately-visible thing: a modal, a rich-text editor, a charting lib, a map. Pair with IntersectionObserver or interaction to trigger the import just before it's needed.

Vendor split

a caching play, not a size play

Pull stable node_modules deps into their own chunk(s). Your app code changes every deploy; React/lodash don't. A separate, content-hashed vendor chunk keeps its filename across deploys → returning users re-download only your app code, vendor stays in cache.

React's official pattern is lazy + Suspense; the import must resolve to a module with a default export. [react.dev]

Vendor splitting in practice: route/component splits cut how much JS loads; vendor splitting changes how well it caches. You configure it (Webpack optimization.splitChunks, Vite/Rollup manualChunks) rather than via import(). The nuance: granularity is a trade-off. One giant vendor chunk means a single dep bump busts the cache for the whole vendor bundle for every user; too many tiny vendor chunks bring back the request-waterfall problem. Split into a few sensible groups (e.g. framework vs the long tail), and let content hashing do the rest. [web.dev — granular chunking]

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

<Suspense fallback={<Skeleton/>}>
  <Checkout/>
</Suspense>
Trap — over-splitting. Every chunk is a request. Split too finely and you trade a big-bundle problem for a request-waterfall problem: dozens of tiny chunks, each a round trip, a fallback spinner, and a discovery delay (chunk B is only found after chunk A loads). Lazy-loading something needed immediately just adds a round trip + layout shift. Rule of thumb: split on routes and on heavy/deferrable components — not on every component. And prefetch the next-likely chunk (Lesson 01 — <link rel=prefetch> / router prefetch) so the network cost is paid before the click.
One-liner

“Split on routes first, then on heavy below-the-fold components — but not per-component. Past a point you're just trading bundle size for a request waterfall. I prefetch the likely-next chunk so the split is invisible to the user.”

The split feels slower: a spinner on first navigation

A classic consequence: you route-split, and now clicking into a route shows a loading spinner — users say the app got slower. You didn't add cost, you moved it: that route's code is no longer in the initial bundle, so it's discovered at click time → fetch → render. The fix is to pay that cost before the click, not to undo the split:

Trade-off — prefetch isn't free. Prefetching spends bandwidth (and mobile data) on a navigation that might never happen. So prefetch on intent (hover/viewport), not eagerly for every link, and respect Save-Data / slow connections. The honest framing: code-splitting trades initial-load time for navigation time — prefetch-on-intent is how you get both.
One-liner

“Splitting moved the cost from load to navigation; I pay it back with prefetch-on-intent — hover or in-viewport — and keep the old page interactive with startTransition. So the click feels instant without re-bloating the initial bundle.”

3Tree-shaking — how it actually works

Tree-shaking = dead-code elimination across modules. The bundler builds a graph of every export and which are actually imported, then drops the unreachable ones. The whole thing rests on one property: [MDN]

Why sideEffects: false matters

A side effect is code that does something just by being imported — a polyfill, import './styles.css', registering a global, mutating a prototype. By default a bundler must be conservative: even if you use nothing from a module, it may have to keep it in case importing it does something. "sideEffects": false in package.json is a promise to the bundler: “my files do nothing on import — if an export isn't used, drop the whole file.” That upgrades tree-shaking from pruning unused exports to pruning entire unused modules. [webpack]

// package.json — "I have no import-time side effects"
{ "sideEffects": false }

// or list the files that DO (so CSS isn't dropped):
{ "sideEffects": ["*.css", "./src/polyfills.js"] }
Trap — what silently defeats tree-shaking. (1) CommonJS deps — can't be shaken; (2) a wrong sideEffects:false that drops your CSS or a needed polyfill; (3) barrel files (index.js re-exporting everything) that pull in a whole library when you wanted one function; (4) importing the whole namespaceimport _ from 'lodash' defeats it, import debounce from 'lodash/debounce' or lodash-es doesn't. Verify with the bundle analyzer, don't assume.
One-liner

“Tree-shaking only works because ESM imports are static — CommonJS can't be shaken. sideEffects:false lets the bundler drop whole unused files, not just unused exports. The usual killers are barrel files and importing a lib's whole namespace.”

4The bundler landscape — and why the new ones are fast

Have an opinion here. The split that matters: old guard written in JavaScript (Webpack, Rollup) vs new guard written in Go/Rust (esbuild, SWC, Turbopack, Oxc/Rolldown) that are 10–100× faster at the parse/transform step. And the architectural trick Vite popularized: don't bundle at all in dev.

Tool What / written in Sweet spot & trade-off
Webpack JS — the mature incumbent Vast plugin/loader ecosystem, handles anything. Cost: complex config, slower builds & dev start. Still everywhere in legacy apps.
Rollup JS — library bundler Cleanest ESM output + best-in-class tree-shaking → long the standard for publishing libraries; was Vite's prod bundler (until Rolldown).
esbuild Go — a transpiler/bundler Extreme speed; was Vite's dep pre-bundler. Thin plugin API & code-splitting → rarely the sole prod bundler for a big app.
Oxc + Rolldown Rust — the unifying toolchain Oxc = a Rust JS/TS toolchain (parser, oxlint, oxfmt, transformer, minifier, resolver); Rolldown = the Rust bundler built on it, Rollup-compatible API. The new engine under Vite.
Vite the framework-agnostic dev/build tool Dev = native ESM, no bundling (see below). Vite 8 (Mar 2026) unified on Rolldown, replacing the old esbuild-dev / Rollup-prod split. The modern default for SPAs.
Turbopack Rust — Webpack's successor Incremental, Rust-fast, by Vercel for Next.js. The Next-native rival to the Vite/Rolldown stack.

SWC (Rust) is the analog for transforms — a Babel replacement Next.js uses to compile TS/JSX fast. [swc.rs] The 2026 theme to name: the JS toolchain is consolidating onto one fast Rust core — Oxc powers Rolldown and oxlint (≈50–100× faster than ESLint) and the formatter, so parser/resolver are shared instead of re-implemented per tool. [oxc.rs] [VoidZero]

Why Vite/esbuild feel instant

The 2026 update worth knowing: Vite originally had a “split-brain” setup — esbuild in dev, Rollup in prod — which meant subtle dev/prod differences. Vite 8 (March 2026) unified both onto Rolldown (the Rust bundler on Oxc), so one engine serves dev and builds prod. The honest framing: the native-ESM dev model is still why dev is instant; Rolldown is what makes the production build Rust-fast and removes the two-bundler inconsistency. [oxc.rs]

One-liner

“Webpack bundles the whole app before serving dev — start scales with app size. Vite serves source over native ESM and transforms only what the browser asks for. And Vite 8 unified dev and prod onto Rolldown, so it's one Rust engine end-to-end instead of esbuild-plus-Rollup.”

Trade-off — don't migrate for fashion. Vite's speed is real, but Webpack's value was its ecosystem maturity — for a huge legacy app with bespoke loaders, a rewrite has real risk and cost. The honest Lead answer: new project → Vite (or the framework's default); existing Webpack app that's fine → measure the pain before a migration. Bundler choice is rarely your top performance lever — what you ship beats which tool ships it.

5Find the weight — and govern it

You can't shrink what you can't see. The workflow:

The Lead move: make it a guardrail, not a cleanup

A one-off bundle diet regresses the moment the next team ships. So you put a size budget in CI that fails the PR when a bundle crosses a threshold — size-limit, bundlesize, or Lighthouse-CI resource budgets. Now bundle size is a team discipline with a number, not a heroic quarterly cleanup. [web.dev]

Full loop

Concept: ship less JS — split by route, tree-shake, prune. Trade-off: over-splitting buys a request waterfall, and a bundler migration has real cost — so I tune granularity and migrate only on measured pain. Anchor: “At the platform-scale, the bundle analyzer showed three teams each pulling a different moment.js version; we swapped to a shared dayjs and added a size-limit CI gate.” Impact: smaller initial JS → faster INP/LCP on mobile, and the gate stops regressions across many teams. Invite: “If we were a single small SPA I'd care less about governance and more about route-splitting; the budget matters because many teams ship into one bundle.”

6Check yourself — scenario quiz

Pick an answer; instant feedback. Push-back style, like the round.

1. Why is a kilobyte of JavaScript more expensive than a kilobyte of image?

2. What creates a separate chunk (a code-split point) for the bundler?

3. Why can ESM be tree-shaken but CommonJS effectively can't?

4. A teammate sets "sideEffects": false and suddenly the app loads with no styles. What happened, and the fix?

scn: the app does import './app.css' in several modules.

5. Your colleague wants to lazy-load every component to “make the bundle tiny.” Your pushback?

6. Why does Vite's dev server start almost instantly while Webpack's slows as the app grows?

7. The bundle is 200 KB bigger than expected. import _ from 'lodash' appears in one util. Best move?

scn: they only use debounce and get.

8. Most Lead answer to “how do you keep bundle size down across many teams?”

9. Why split node_modules into a separate vendor chunk if it doesn't reduce total bytes?

scn: a teammate says "it's the same size either way, so why bother?"

10. An interviewer asks "what's changed in the bundler world lately?" Strongest answer?

11. You route-split, and now the first navigation into a route shows a spinner — users say the app feels slower. Best response?

scn: an interviewer pushing on a real consequence of your last answer.

0 / 11 answered

Try this aloud before next session: “Our hotel-search SPA's initial JS bundle is 1.2 MB and INP is poor on mobile. Walk me through how you'd get it down — first move to last — and how you'd stop it growing again.” Time to 90 seconds.
Good follow-up topics:
“Quiz me out loud, harder” “Walk a bundle-analyzer treemap with me” “magic comments / webpackChunkName?” “How does Module Federation split across MFEs?” “Show me a size-limit CI config” “When is bundling actually unnecessary (HTTP/2)?”