Bundle Size Matters — Tree Shaking, Analysis, and What to Do About It
Day 24 of 40 — React System Design Series

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.
Hey everyone, Richa here! 👋

Day 24. And today we talk about the thing that silently tanks performance in almost every React app I've ever audited.
Bundle size.
Not the kind of problem that shows up obviously. No error messages. No crashes. Just slow initial load times that users quietly tolerate — or quietly leave because of.
Every 100KB of JavaScript costs approximately:
~300ms on a fast 4G connection (download)
~50–100ms of parse and compile time on an average mobile device
Memory overhead for every byte in the heap
That might not sound like much. But a 2MB bundle on a mid-range Android phone is genuinely painful. And 2MB bundles are more common than you'd think.

Step 1 — Measure first
Before optimising anything — measure what you have.
Vite bundle analyser:
npm install --save-dev rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react(),
visualizer({
open: true, // auto-opens in browser after build
gzipSize: true, // shows gzip sizes (closer to what users actually download)
brotliSize: true,
filename: 'dist/stats.html',
}),
],
});
npm run build
A treemap opens showing every package in your bundle, sized by how much space it takes. This is where every bundle optimisation session starts.

What to look for:
Large packages in your main bundle that should be in a lazy chunk (Day 22)
Packages that appear multiple times (duplicate dependencies)
Packages that seem bigger than expected (moment.js is infamous for this)
Packages you're not sure why you're importing at all
Tree shaking — what it is and why it sometimes fails
Tree shaking is your bundler's (Vite/webpack) ability to remove code that's imported but never used.
// mathUtils.ts — exports 10 functions
export function add(a: number, b: number) { return a + b; }
export function subtract(a: number, b: number) { return a - b; }
export function multiply(a: number, b: number) { return a * b; }
// ... 7 more functions
// component.ts — only uses add
import { add } from './mathUtils';
With tree shaking, the bundler removes subtract, multiply, and the other 7 functions from the final bundle. Only add is included.
Tree shaking works when:
The module uses ES modules (
import/export) — not CommonJS (require)The imports are static — not dynamic strings
When tree shaking fails — and how to fix it:
// ❌ Imports the entire lodash library — tree shaking often fails with lodash
import _ from 'lodash';
const sorted = _.sortBy(users, 'name');
// ✅ Import only what you need — always tree-shakeable
import sortBy from 'lodash/sortBy';
const sorted = sortBy(users, 'name');
// ✅ Even better — use lodash-es (ES module version)
import { sortBy } from 'lodash-es';
// ❌ Imports all of date-fns
import * as dateFns from 'date-fns';
// ✅ Named imports from date-fns — fully tree-shakeable
import { format, parseISO, differenceInDays } from 'date-fns';
// ❌ Imports all Material UI icons (thousands of icons)
import * as Icons from '@mui/icons-material';
const { Settings } = Icons;
// ✅ Import only the icons you use
import SettingsIcon from '@mui/icons-material/Settings';
The moment.js problem — and the fix
moment.js is the most common bundle bloat offender. It includes locale data for every language in the world by default.
moment.js in your bundle: ~230KB minified, ~65KB gzipped
Even if you only use moment().format('DD/MM/YYYY').
The fix — switch to a lighter alternative:
| Library | Gzipped size | Notes |
|---|---|---|
moment |
~65KB | Avoid in new projects |
date-fns |
~13KB (tree-shaken) | Best for most use cases |
dayjs |
~2KB | Smallest, moment-compatible API |
Temporal (native) |
0KB | Future standard, limited support now |
# Remove moment
npm uninstall moment
# Add date-fns (or dayjs)
npm install date-fns
// Before
import moment from 'moment';
const formatted = moment(date).format('DD MMMM YYYY');
// After
import { format } from 'date-fns';
const formatted = format(new Date(date), 'dd MMMM yyyy');
Saving: ~60KB gzipped. For one library swap.
Analysing and replacing large dependencies
After running the bundle analyser, here's a process for each large package you find:
1. Is it in the right chunk?
If it's in the main bundle but only used on one route — lazy load it (Day 22). This is often the highest-impact fix.
2. Are you using all of it?
// Are you importing the whole library or just one function?
import { debounce } from 'lodash-es'; // just debounce — ~1KB
// vs the whole thing
import _ from 'lodash'; // 70KB+
3. Is there a lighter alternative?
| Heavy package | Lighter alternative | Size saving |
|---|---|---|
moment |
date-fns or dayjs |
~60KB |
lodash |
lodash-es + named imports, or native JS |
30–60KB |
axios |
ky or native fetch |
~15KB |
react-icons (all icons) |
Import specific icon packages | 100KB+ |
chart.js |
recharts (smaller) or lazy load |
50–100KB |
highlight.js (all languages) |
highlight.js with only needed languages |
100KB+ |
4. Can you lazy load it?
// Heavy chart library — only load when the chart is rendered
const RevenueChart = lazy(() => import('./components/RevenueChart'));
// recharts is only in the RevenueChart chunk — not the main bundle
Import cost — the VS Code extension
Install Import Cost in VS Code. It shows the size of every import inline:
import { format } from 'date-fns'; // 13KB
import _ from 'lodash'; // 72.5KB ← notice this immediately
import { debounce } from 'lodash-es'; // 1.2KB ← much better
You catch bundle bloat at the point of writing the code — not after building.

Production build checklist
Before shipping a performance-sensitive app:
# 1. Build and open bundle analyser
npm run build
# → Check the visualiser — anything unexpected in the main bundle?
# 2. Check gzip sizes
ls -lh dist/assets/*.js
# → Main chunk should ideally be < 200KB gzipped
# 3. Lighthouse audit
# Chrome DevTools → Lighthouse → Mobile → Performance
# → Check "Avoid enormous network payloads"
# → Check "Remove unused JavaScript"
# 4. WebPageTest
# https://www.webpagetest.org
# → Test on a real slow connection (3G)
# → Real device performance
The npm bundle size tools
Before installing any new dependency:
bundlephobia.com — paste any npm package name, see its size and tree-shakeable alternatives:
moment → 232.6KB minified, 65.9KB gzip
date-fns → 78.4KB minified, 12.8KB gzip (but tree-shakeable to <3KB for one function)
dayjs → 6.5KB minified, 2.6KB gzip
pkg-size.dev — similar, with more detailed breakdown
Make this part of your process: before npm install heavy-library, check bundlephobia. Know what you're signing up for.

Quick wins summary
| Action | Typical saving |
|---|---|
| Route-level code splitting | 40–80% main bundle reduction |
Replace moment with date-fns |
~60KB gzip |
| Named imports from lodash-es | 30–60KB |
| Lazy load chart library | 50–150KB |
| Named icon imports instead of full icon pack | 50–200KB |
| Remove unused dependencies | Varies |
You don't need all of these. Usually 2–3 changes from the bundle analyser cut your main bundle in half.
Today's takeaway

Bundle size is a hidden performance tax. Users pay it on every first visit — slower load, higher data usage, more battery drain on mobile.
The process:
Measure — bundle visualiser shows you exactly what's taking space
Split — lazy load what doesn't need to be in the main bundle (Day 22)
Replace — swap heavy libraries for lighter alternatives (moment → date-fns)
Import properly — named imports so tree shaking works
Prevent regression — Import Cost extension, bundlephobia before every install
Run this process once on any mature app and you'll almost certainly find 200–500KB of easy wins.
See you tomorrow for Day 25 — Using the React Profiler: finding real bottlenecks in real apps.

← Day 23 — List Virtualisation → Day 25 — React Profiler
Part of the React System Design Series — 40 days, one topic per day.






