Bundle Analysis and Optimization
You're Probably Shipping More JavaScript Than You Think
Here's a reality check: the median JavaScript payload for a modern web app is about 500KB compressed. On a 3G connection, that's 3-5 seconds just to download — before the browser even starts parsing and executing. And most of that JavaScript? Your users never need it for the page they're on.
Bundle analysis turns a black box into a transparent map. Once you see what's inside your bundle, the optimizations become obvious.
Think of bundle analysis like a financial audit. Your JavaScript bundle is your company's spending. Without an audit, you vaguely know "it's big." With an audit, you discover you're paying for three separate subscriptions to the same service (duplicate dependencies), a premium tool you used once six months ago (abandoned libraries), and express shipping on items that aren't urgent (code that could be lazy-loaded). The audit doesn't fix anything — it reveals where the money goes so you can make informed cuts.
The Analysis Toolbox
webpack-bundle-analyzer / @next/bundle-analyzer
The gold standard. Generates an interactive treemap where each rectangle's area corresponds to the module's size.
// next.config.js (Next.js)
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
ANALYZE=true pnpm build
The treemap shows three size metrics:
- Stat size — raw size before processing (rarely useful)
- Parsed size — size after minification (CPU cost: the browser must parse and execute this)
- Gzip size — size after compression (network cost: what actually downloads)
Always focus on parsed size for execution cost and gzip size for download cost.
source-map-explorer
Uses source maps for precise byte-level attribution. More accurate than webpack-bundle-analyzer for understanding which specific source files contribute the most bytes.
npx source-map-explorer .next/static/chunks/main-*.js
bundlephobia.com
Check the cost of any npm package before you install it:
- Bundle size (minified + gzipped)
- Download time on 3G
- Whether it's tree-shakable
- Full dependency tree
Import Cost (VS Code extension)
Shows the import cost inline in your editor as you type:
import { debounce } from 'lodash'; // 72KB
import debounce from 'lodash/debounce'; // 1.2KB
import { debounce } from 'lodash-es'; // 1.5KB (tree-shakable)
Tree Shaking Verification
Tree shaking sounds great in theory. But how do you verify it's actually working?
The Single-Import Test
// test-shake.js
import { oneFunction } from './my-library';
console.log(oneFunction());
Build this, then check the output size. If my-library exports 50 functions and your bundle is significantly larger than the one function you imported, tree shaking is failing.
Common Tree Shaking Failures
- Missing
sideEffects: falsein package.json — the bundler conservatively includes all modules - CommonJS format —
require()can't be statically analyzed - Barrel files with side effects —
index.tsre-exports trigger inclusion of all re-exported modules - Top-level function calls —
registerComponent()at module scope is a side effect
{
"name": "my-library",
"sideEffects": false
}
Adding sideEffects: false to your library's package.json tells bundlers: "if exports from these modules aren't used, the entire module can be safely dropped."
Duplicate Dependency Detection
One of the most common sources of bundle bloat: the same library included at different versions because different packages depend on different versions.
# Find duplicate packages
npm ls lodash
# or
pnpm why lodash
my-app
├── lodash@4.17.21
├── package-a
│ └── lodash@4.17.15
└── package-b
└── lodash@4.17.20
Three copies of lodash. That's ~216KB of wasted space.
Fixes:
// package.json — npm overrides
{
"overrides": {
"lodash": "4.17.21"
}
}
// package.json — pnpm overrides
{
"pnpm": {
"overrides": {
"lodash": "4.17.21"
}
}
}
# Deduplicate compatible versions
npm dedupe
Duplicates aren't always visible by name. lodash and lodash-es are different packages with the same utilities. moment and dayjs both handle dates. Two different packages might bundle their own copy of a utility like tslib or regenerator-runtime. Look for suspiciously similar large modules in the treemap.
Barrel File Cost Analysis
Barrel files (index.ts that re-export everything) are one of the biggest silent bundle killers:
// src/components/index.ts — the barrel
export { Button } from './Button';
export { Modal } from './Modal'; // imports framer-motion (60KB)
export { DataTable } from './DataTable'; // imports tanstack-table (50KB)
export { Chart } from './Chart'; // imports recharts (150KB)
// Your page — only needs Button (2KB)
import { Button } from '@/components';
// But you might get Button + framer-motion + tanstack-table + recharts = 262KB
The fix: Import directly from the source module.
import { Button } from '@/components/Button';
To audit barrel file impact:
- Find all barrel imports in your codebase (imports from directories or index files)
- Build with the barrel import, note the bundle size
- Change to a direct import, rebuild, compare
- If the direct import is significantly smaller, the barrel is leaking
Setting Performance Budgets
A budget is a maximum allowed size for your JavaScript bundles. Without one, bundle size creeps up with every new feature and dependency until someone notices the app is slow.
webpack Budget Configuration
module.exports = {
performance: {
maxAssetSize: 250000,
maxEntrypointSize: 250000,
hints: 'error',
},
};
Bundlesize / size-limit
// package.json
{
"size-limit": [
{ "path": "dist/index.esm.js", "limit": "15 KB" },
{ "path": "dist/index.cjs.js", "limit": "18 KB" }
]
}
npx size-limit
Run this in CI. If a PR pushes the bundle over budget, the build fails. This creates the most effective forcing function: developers must actively optimize or reduce scope when they hit the budget ceiling.
Ratcheting Strategy
Don't set an aspirational budget — set one based on your current size:
- Measure your current bundle sizes
- Set the budget at current size + 5%
- When you optimize and shrink the bundle, lower the budget to the new size + 5%
- Repeat
This prevents regression while avoiding unrealistic targets that everyone ignores.
Optimization Strategies Summary
| What developers do | What they should do |
|---|---|
| Looking at stat size in the bundle analyzer Stat size is before any processing — it doesn't represent what users actually download or what the browser parses. Parsed and gzip sizes are the actionable metrics. | Focus on parsed size (CPU cost) and gzip size (network cost) |
| Installing a library without checking its size first A 'lightweight' utility might pull in 200KB of transitive dependencies. Five minutes on bundlephobia saves hours of debugging bundle bloat later. | Check bundlephobia.com before every npm install |
| Setting aspirational bundle budgets that nobody can meet Unrealistic budgets get ignored or disabled. A budget slightly above current size prevents regression while remaining achievable. | Set budgets based on current size + 5%, then ratchet down after optimizations |
| Importing from barrel files for convenience Barrel files can silently pull in hundreds of KB of unused code due to side effects, CSS imports, or top-level function calls in re-exported modules. | Import directly from source modules for reliable tree shaking |
- 1Run bundle analysis regularly — you can't optimize what you can't see. Use ANALYZE=true with your build command.
- 2Focus on parsed size for CPU cost and gzip size for network cost. Know your audience: weak devices care about parsed size, slow networks care about gzip size.
- 3Check bundlephobia.com before adding any dependency. Compare alternatives. Prefer built-in browser APIs (Intl, structuredClone, crypto.subtle) over libraries.
- 4Duplicate dependencies are the most common waste. Use npm ls, npm dedupe, and overrides to resolve them.
- 5Import directly from source modules, not barrel files. Verify tree shaking with single-import tests.
- 6Set and enforce bundle budgets in CI using size-limit. Ratchet down after optimizations.