modern-react-spa

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:

  1. Which chunks are the largest — start here.
  2. Which dependencies inside a chunk are taking the space — drill into the biggest slice.
  3. 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 your dist/.

The 2026 rule of thumb:

Library size (gzip)Verdict
< 5 KBTrivial; add freely
5–20 KBConsider; check for alternatives
20–50 KBStrong reason to add only
> 50 KBAlmost 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 libSize (gzip)Replace withNew size
moment~70 KBdate-fns/format (per-fn imports)~3 KB
moment~70 KBluxon~22 KB
moment~70 KBTemporal (native, when stable)0 KB
lodash~25 KBlodash-es/X (per-fn imports)~1–2 KB
lodash~25 KBNative + radashvaries
react-helmet~5 KBNative <title> / <meta> (Ch 2 §2.4)0 KB
Full @mui/icons~80 KBlucide-react (per-icon imports)~0.5 KB each
axios~15 KBNative fetch + small wrapper~0 KB
formik~13 KBreact-hook-form~9 KB
formik~13 KBNative <form action> (Ch 2)0 KB
Full chart.js~60 KBlightweight-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.
  • axiosfetch + a 20-line wrapper covers ~95 % of cases.
  • react-helmet — superseded by React 19.2’s native metadata (Ch 2 §2.4).

🪤 Common Pitfalls

  1. Lazy-loading the most-visited route → extra round-trip for nothing.
  2. Over-splitting into many tiny chunks.
  3. Adding a library based on README size, not bundle-after-tree-shake size.
  4. Importing lodash as the barrel instead of lodash-es/get.
  5. Bundling react-dom into a library (Ch 11.2 — should be a peer dep).
  6. Ignoring duplicated modules across chunks → you’re shipping React twice.
  7. manualChunks over-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

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.

Topics in this chapter (4)
  1. 31.1 Reading the bundle visualizer
  2. 31.2 Route-level code-splitting
  3. 31.3 Dependency cost analysis
  4. 31.4 Replacing heavy libs