01 — Bundle Anatomy
A bundle is every byte of JavaScript the browser must parse before your app is interactive. On the right is one such bundle — a real-shape SPA dashboard with four routes (/home, /dashboard, /settings, /admin), three heavy feature libraries (charts, rich editor, PDF renderer), a couple of barrel-imported utility libraries, and the framework runtime. Hover the treemap. Every rectangle is a module; area scales linearly with gzipped KB.
The total is 1,131KB gzipped. On a mid-range Android with 4× CPU slowdown and 3G bandwidth, that's roughly 6 seconds of parse + execute before React even begins rendering. None of those 6 seconds put pixels on screen. The user is staring at white.
Three things make this bundle so large. First, every route's code is in main.js, so visiting /home ships /admin's editor and PDF renderer too. Second, several libraries are imported with import * as ... or barrel re-exports, which prevent tree-shaking — the bundler can't tell which exports the app actually uses. Third, polyfills and locale data ship for environments and languages your users will never see (every moment locale, every browser polyfill, every icon glyph).
The headline number — initial load size — is what you optimize. Total payload only matters for total bandwidth; initial load is what blocks first paint.
02 — Code Splitting
Code splitting is the decision to not ship all code upfront. Instead of one main.js, the bundler emits multiple chunks that load on navigation. Route-based splitting is the highest-impact, lowest-risk variant: every page-component file becomes its own chunk.
The pattern is one line of code per route:
lazy() returns a component that calls import() the first time it renders. The bundler sees the import() expression, traces the dependency graph from that file, and emits a separate chunk. The framework runtime, router, and shell layout stay in the “initial” chunk because they're needed before any route renders. Everything route-specific moves into its own chunk file: dashboard.<hash>.js, admin.<hash>.js, etc.
The result: initial load drops from 1,131KB 447KB. The shell + home route are all the user pays for on first paint. /dashboard (357KB) and /admin (305KB) sit on disk until the user navigates.
Webpack uses a different API. The same intent lives under optimization.splitChunks:
Don't conflate the two. Vite and Rollup share the Rollup config surface — manualChunks, output formats, and tree-shaking heuristics behave the same. Webpack's splitChunks is a different mental model (cache groups, priorities, automatic splitting heuristics). Mixing the docs will frustrate every Vite developer who Googles “Vite splitChunks” and finds a stale Webpack tutorial.
03 — Tree Shaking
Tree shaking eliminates dead code at build time by walking the ESM import graph. If a module is imported but the importer only uses one of its exports, the bundler marks the rest as unreachable and drops them from the output. Two things have to be true for it to work: the modules need to be ESM (not CJS), and the bundler needs to trust the module has no side effects.
Why CJS can't tree-shake. require("./lib") is a function call. The bundler can't tell at build time which branches of require() will execute at runtime — require(condition ? "a" : "b") is legal CJS. Worse, module.exports is a mutable object; nothing stops a downstream consumer from doing exports[dynamicKey]. So the bundler has to assume every export of a CJS module might be used and includes them all.
Why ESM can. import { debounce } from "lodash-es" is a static declaration the bundler can resolve before the file even runs. It marks debounce as live, then walks the export list of lodash-es and marks the rest as dead. The dead exports never appear in the output bundle.
The barrel-file trap. A “barrel” is index.ts that re-exports everything from a directory:
Consumers write import { IconHome } from "@/icons". Looks clean. But the bundler can't always prove that IconUser has no side effects, so it keeps the entire barrel. Result: importing one icon ships 200. Most bundlers handle simple export * correctly, but the moment a barrel adds custom logic (export const all = [...]), tree-shaking collapses and the whole tree ships.
The fix is to import the leaf file directly:
Or configure the bundler's barrel-file optimizer. Next.js has optimizePackageImports (next.config.js), Vite's vite-plugin-optimize-deps does similar work, and esbuild handles many barrels natively.
The sideEffects: false flag is the author's signed contract: nothing in this package mutates global state on import. The bundler uses it to drop entire modules if no one imports anything from them. For libraries with one or two side-effectful files (CSS imports, polyfills), use an array: "sideEffects": ["*.css", "./src/polyfill.js"].
Modern bundlers — esbuild, SWC, Rollup, Turbopack — all do tree-shaking automatically as long as you give them ESM and honest sideEffects metadata. Webpack 5+ does too, with caveats around how it serializes the module graph. The win in this lesson is 88KB off the shared chunk: lodash full lodash-es per-method (72 4KB), moment + locales date-fns (232 16KB on the lazy dashboard route), core-js shipping every polyfill browserslist-targeted polyfills (45 6KB), icon barrel per-file imports (30 4KB).
04 — Dynamic Imports
Route splitting is one decision: when the user navigates to a route, load that route's code. Dynamic imports are a more granular decision: even within a route, some libraries are heavy enough that you don't want to load them until the user actually triggers the feature. A chart library, a rich text editor, a PDF renderer — these can be 100-300KB each, and most users never open them.
The pattern is identical to lazy() for routes — just applied to a component (or any module) inside an already-loaded route:
The bundler emits chart-lib.<hash>.js. DashboardPage ships without the chart code; the moment the user clicks “Show analytics”, the browser fetches that chunk and renders it.
Prefetch the chunk before the click so the user never sees the skeleton:
webpackPrefetch: true adds <link rel="prefetch"> to the document head, telling the browser to fetch the chunk during idle time after the main page settles. By the time the user clicks the button, the chunk is in the HTTP cache and the Suspense fallback never appears.
modulepreload is the standards-track equivalent for ESM chunks. It tells the browser to fetch and parse the module immediately at high priority, ready for instant import. Use it for chunks you're certain to need soon (the next route the user will hit), reserve prefetch for chunks you only might need.
React Server Components shift the bar. With Next.js 15+ App Router or Remix v2, server components don't ship any JavaScript to the client. The chart you render server-side only sends HTML; the client never downloads chart-lib at all. Dynamic imports are still useful for interactive components (the ones marked "use client"), but the entire category of “data view” components can move out of the client bundle:
The dashboard route's client bundle drops from 357KB to whatever the interactive shell costs — often 20-30KB. The chart library never enters the browser's JS heap.
05 — Vendor Chunk
The first four optimizations attack initial load size. The fifth attacks load size on every deploy — the cost of shipping a new version when most of the bundle hasn't changed.
A vendor chunk is a separately-emitted file containing dependencies that change rarely: the framework runtime (React, React DOM, react-router), low-churn utilities (date-fns, icon glyphs, polyfills), and any library you import but don't author. The bundler hashes the chunk's content into the filename — vendor.b3a91f.js — and the server sets Cache-Control: public, max-age=31536000, immutable. The browser caches it for a year and never revalidates.
The first visit pays the same total cost: vendor.js + app.js download together. But every subsequent deploy looks like this:
- Developer changes one line in
DashboardPage.tsx. - The bundler hashes the changed module only
dashboard.<newhash>.jsgets a new filename. vendor.b3a91f.jskeeps its old hash because nonode_modulescontent changed.- User's browser fetches
dashboard.<newhash>.js(45KB), hits the disk cache forvendor.b3a91f.js(108KB), and renders in 116KB of network transfer instead of 224KB.
That's the magic — the warm-cache load is now 116KB. Across a year of deploys, the vendor chunk stays cached. Only the app-shell + route chunks get re-downloaded.
The trap is over-splitting. Every chunk adds an HTTP request. HTTP/2 multiplexing helps, but each request still has a TLS overhead, a priority-tree position, and a parse step. Below ~5KB per chunk, the overhead-to-content ratio flips and you've made things slower. The sweet spot is 3-8 chunks: vendor (framework + immutable libs), shared (app shell + ui-kit + utils), and one chunk per route.
Brotli over gzip. Modern CDNs (Vercel, Cloudflare, Netlify, Fastly) serve Brotli compression by default when the request header sends Accept-Encoding: br. Brotli compresses JavaScript 15-20% smaller than gzip — the 116KB warm-cache load drops to ~95KB. Static pre-compression at build time (brotli CLI, vite-plugin-compression) means the CDN serves the pre-compressed file directly instead of compressing on demand. The difference is real: 95KB vs 116KB for the same user-perceived content.
06 — Bundle Analysis
Optimizations interact. Tree-shake before you route-split and the shared chunk hides waste that per-route shaking would have caught. Vendor-chunk before tree-shaking and the bundler can't remove unused exports from libraries that are now in their own chunk. Dynamic-import without route-splitting still ships every feature library in the monolith. Order matters.
The lab on the right is now a sequencer. Drag the four optimization cards into any order; the treemap reflows in real time. The headline counter shows initial load after each step. The build budget is 250KB on first paint; the stretch goal is a warm-cache load under 130KB.
A modern bundle analyzer (webpack-bundle-analyzer, rollup-plugin-visualizer, vite-bundle-visualizer, source-map-explorer) is the diagnostic tool you reach for when an existing bundle is too large. It produces a treemap exactly like the one in the lab — every module sized by its contribution. You read the treemap looking for three things:
Duplicates. Two copies of the same library in different chunks, usually because two packages depend on different minor versions. The analyzer shows [email protected] AND [email protected] both in the output. Fix: lock the version with a peer dependency or a yarn/npm resolution override.
Barrels that didn't shake. A library showing far more bytes than the named exports you import. Common culprits: @mui/icons-material, @chakra-ui/react, lodash (full IIFE), moment with locales, rxjs without operator-level imports. Fix: deep imports (@mui/icons-material/Home), or per-method imports (lodash/debounce), or migrate to ESM-only alternatives (date-fns, dayjs).
Polyfills for browsers you don't support. core-js shipping Promise for browsers that have it natively. regenerator-runtime because of an old preset-env config. Fix: pin browserslist to your real target floor and rebuild.
Production tooling stack:
webpack-bundle-analyzer/vite-bundle-visualizer— interactive treemap of the build output. Run on every deploy; archive the HTML so you can diff bundle composition over time.source-map-explorer— points at the production map files to see which source files contributed which bytes. Useful when minification has obscured module names.- Lighthouse CI — runs in your PR pipeline. Enforces a hard byte budget (
resource-summary:script:size). PRs that bust the budget fail before merge. No human judgment, no negotiation. @next/bundle-analyzer/ Turbopack profiler —Next.js–native, shows server vs client bundle composition separately. Server-component bytes don't count toward the client payload.bundlejs.com— paste any npm package, see its real ESM size. Useful when comparing two libraries before you adopt one.
The capstone on the right: apply the four optimizations (route-split, tree-shake, dynamic-import, vendor-chunk) in any order. Watch the treemap fracture, shrink, lift, and migrate. Notice that route-split first lets per-route tree-shaking catch waste that monolith-wide shaking missed. Notice that vendor-chunk last gives you a warm-cache load under 130KB — the same dashboard, with cache, ships less than a quarter of what the monolith shipped.