Chapter 31
Bundle Optimization
Bundle optimization for React 19.2 — reading the bundle visualizer, route-level code-splitting, dependency cost analysis, and replacing heavy libraries.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can read a bundle-visualizer report and identify the chunks worth optimising, code-split at the route boundary (and know when not to), analyse dependency cost before adding a new library, and replace the most-common heavyweight deps with smaller equivalents.
🧭 Prerequisites — Ch 17 (Vite), Ch 30 (budget).
🔹 31.1 Reading the bundle visualizer
Set up rollup-plugin-visualizer (covered in Ch 17 §17.3). Run vite build --mode analyze. A browser opens with a sunburst diagram of dist/.
What to look at:
┌─ Visualizer ── dist/assets ────────────────────────────────────┐
│ ●●●●●●●●●●●●●●●●●●●● react-vendor 42 kB (gzip 14 kB) │
│ ●●●●●●●● Invoices 28 kB (gzip 9 kB) │
│ ●●●●●●● Tenants 24 kB (gzip 8 kB) │
│ ●●●●● router 11 kB (gzip 4 kB) │
│ ●●●● index 18 kB (gzip 6 kB) │
│ ●●●●●●●●●●● charts 87 kB (gzip 24 kB) ◀── investigate │
└─────────────────────────────────────────────────────────────────┘
Prioritise by gzipped size (what the browser actually downloads), not raw. A 200 KB JSON config might gzip to 10 KB; a 50 KB Lottie animation might gzip to 48 KB. The latter is the bigger problem.
The visualization tells you three things:
- Which chunks are the largest — start here.
- Which dependencies inside a chunk are taking the space — drill into the biggest slice.
- Whether code is duplicated across chunks — same module appearing twice means you’re shipping it twice.
🔹 31.2 Route-level code-splitting
Most users hit 2–3 routes per session. Shipping all 20 routes’ JS upfront is wasted.
import { lazy } from 'react';
const Dashboard = lazy(() => import('@/routes/Dashboard'));
const Profile = lazy(() => import('@/routes/Profile'));
Vite (Rollup) creates one chunk per dynamic import() boundary automatically. Each route lazy-loads on first visit; cached afterward.
Don’t lazy-load the route the user lands on most.
If 80 % of sessions start on /dashboard, including it in the initial bundle saves a click-to-fetch round-trip. Measure with RUM (Ch 30.4); the route that has 100 % first-visit traffic should be inlined.
Don’t over-split.
Twenty 2 KB chunks have higher wall-clock load time than five 8 KB chunks on slow connections. HTTP/2 helps but doesn’t fully erase per-request overhead.
┌─ before code-splitting ──────────────────┐
│ main.js 350 KB ◀── single chunk │
│ TTI: 3.2 s │
└──────────────────────────────────────────┘
┌─ after route-split ──────────────────────┐
│ main.js 80 KB ◀── shell │
│ react-vendor.js 42 KB ◀── cached │
│ Dashboard.js 35 KB │
│ Invoices.js (lazy) 28 KB │
│ Tenants.js (lazy) 24 KB │
│ TTI: 1.8 s │
└──────────────────────────────────────────┘
🔹 31.3 Dependency cost analysis
Before adding a new library, check its weight:
- https://bundlejs.com/ — paste a package name, see gzipped + minified size.
- https://bundlephobia.com/ — older, similar idea. Still useful for “has this changed?” trend.
vite-bundle-analyzer— runs locally against yourdist/.
The 2026 rule of thumb:
| Library size (gzip) | Verdict |
|---|---|
| < 5 KB | Trivial; add freely |
| 5–20 KB | Consider; check for alternatives |
| 20–50 KB | Strong reason to add only |
| > 50 KB | Almost always a wrong choice |
Tree-shake-ability matters more than declared size.
A library declared as 200 KB but tree-shaken to 4 KB (because you imported only one helper) is fine. A library declared as 30 KB that imports the whole on first reference is heavier in practice. Check the visualizer post-build, not the README.
🔹 31.4 Replacing heavy libs
The most common offenders and their lighter replacements:
| Heavy lib | Size (gzip) | Replace with | New size |
|---|---|---|---|
moment | ~70 KB | date-fns/format (per-fn imports) | ~3 KB |
moment | ~70 KB | luxon | ~22 KB |
moment | ~70 KB | Temporal (native, when stable) | 0 KB |
lodash | ~25 KB | lodash-es/X (per-fn imports) | ~1–2 KB |
lodash | ~25 KB | Native + radash | varies |
react-helmet | ~5 KB | Native <title> / <meta> (Ch 2 §2.4) | 0 KB |
Full @mui/icons | ~80 KB | lucide-react (per-icon imports) | ~0.5 KB each |
axios | ~15 KB | Native fetch + small wrapper | ~0 KB |
formik | ~13 KB | react-hook-form | ~9 KB |
formik | ~13 KB | Native <form action> (Ch 2) | 0 KB |
Full chart.js | ~60 KB | lightweight-charts | ~25 KB |
The general pattern: pick libraries that ship modular ESM and let you import only what you need. The bundle size matches your usage, not the library’s surface area.
Specific 2026 deletions:
moment— frozen project; no reason to use it in 2026.axios—fetch+ a 20-line wrapper covers ~95 % of cases.react-helmet— superseded by React 19.2’s native metadata (Ch 2 §2.4).
🪤 Common Pitfalls
- Lazy-loading the most-visited route → extra round-trip for nothing.
- Over-splitting into many tiny chunks.
- Adding a library based on README size, not bundle-after-tree-shake size.
- Importing
lodashas the barrel instead oflodash-es/get. - Bundling
react-dominto a library (Ch 11.2 — should be a peer dep). - Ignoring duplicated modules across chunks → you’re shipping React twice.
manualChunksover-splitting (Ch 17.2).
✅ Recap
- Read the visualizer; prioritise by gzipped size.
- Route-split most routes; inline the most-visited one.
- Bundle-cost-check every library before adding it.
- Replace the canonical heavies: moment, lodash, react-helmet, axios.
- Tree-shake-ability is more important than declared size.
🔗 Further Reading
- https://bundlejs.com/ — one-off
npmpackage size lookups (live bundling). - https://bundlephobia.com/ — historical size + treeshake report.
- https://github.com/btd/rollup-plugin-visualizer —
rollup-plugin-visualizerfor sunburst reports. - Ch 17 (Vite — manualChunks), Ch 30 (budgets), Ch 32 (runtime perf).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.