Bundle Analysis
You Can't Optimize What You Can't See
Be honest — do you actually know what's in your JavaScript bundle? Most developers don't. They install dependencies, import utilities, trust the bundler, and then wonder why their "simple" Next.js app ships 800KB of JavaScript.
Bundle analysis makes the invisible visible. It takes a blob of minified JavaScript and turns it into a visual map showing exactly which libraries, components, and utilities are eating your users' bandwidth. Every optimization starts here.
Bundle analysis is like an X-ray for your JavaScript. The patient (your bundle) looks fine on the outside — it loads, it works. But the X-ray reveals the internal structure: a 150KB date library used once, two copies of the same utility, a full icon set when you use three icons. You cannot treat what you cannot see. The treemap is your diagnostic tool.
webpack-bundle-analyzer / @next/bundle-analyzer
The standard tool for visual bundle analysis. It generates an interactive treemap where each rectangle's area is proportional to the module's size.
Setup for Next.js
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your next config
});
ANALYZE=true npm run build
This opens an interactive treemap in your browser showing every module in your client and server bundles.
Reading the Treemap
The treemap has a hierarchy: bundle → chunks → modules → files.
Treemap structure:
┌─────────────────────────────────────────────┐
│ main-abc123.js (245KB) │
│ ┌───────────────┐ ┌──────────┐ ┌─────────┐ │
│ │ react-dom │ │ lodash │ │ your │ │
│ │ (128KB) │ │ (72KB) │ │ code │ │
│ │ │ │ │ │ (45KB) │ │
│ └───────────────┘ └──────────┘ └─────────┘ │
└─────────────────────────────────────────────┘
What the sizes mean:
- Stat size: Raw size before any processing. Rarely useful.
- Parsed size: Size after minification but before compression. This is what the browser must parse and execute. The performance-critical number for CPU-bound metrics.
- Gzip size: Size after gzip compression. This is what travels over the network. The performance-critical number for bandwidth-bound metrics.
Always look at parsed size for execution cost and gzip size for download cost.
Identifying Duplicate Dependencies
Here's one that'll make you groan: the most common bundle waste is the same library included twice at different versions.
Treemap showing duplicates:
┌──────────────────────────────────────┐
│ node_modules │
│ ┌──────────┐ ┌──────────┐ │
│ │ lodash │ │ lodash │ │
│ │ 4.17.21 │ │ 4.17.15 │ │
│ │ (72KB) │ │ (72KB) │ ← 72KB │
│ │ │ │ │ wasted │
│ └──────────┘ └──────────┘ │
└──────────────────────────────────────┘
This happens when two dependencies require different versions of the same library. Dependency A needs lodash@^4.17.20, dependency B has lodash@4.17.15 locked in its own node_modules.
How to fix:
# Find which packages pull in duplicate lodash
npm ls lodash
# Deduplicate — forces compatible versions to resolve to one copy
npm dedupe
# Or pin a resolution in package.json (npm overrides)
{
"overrides": {
"lodash": "4.17.21"
}
}
Some duplicates are invisible in the treemap because they have different names. lodash and lodash-es are separate packages containing the same utility functions. If one dependency imports lodash and your code imports lodash-es, you ship both (~144KB). Similarly, moment and dayjs might both exist if different dependencies pull them in for date formatting.
Finding Unexpectedly Large Modules
Now for the fun part. Sort by size and prepare to be surprised:
Common offenders:
moment.js → 72KB (includes all locales by default)
lodash (full) → 72KB (use lodash-es + tree shaking, or individual imports)
date-fns (full) → 80KB (individual function imports tree-shake well)
core-js → 60KB+ (polyfills you may not need with modern browsers)
@fortawesome → 500KB+ (ships every icon; import individually)
highlight.js → 180KB+ (all languages; register only what you need)
recharts → 150KB+ (depends on d3 modules)
For each large module, ask yourself four questions:
- Is it even used? Maybe it's a leftover from a feature someone removed six months ago
- Is the whole thing needed? Maybe you use one function from a 72KB library
- Is there a lighter alternative?
dayjs(2KB) vsmoment(72KB),clsx(1KB) vsclassnames(3KB) - Can it be lazy-loaded? Charts, editors, and rich text formatting can load on demand
source-map-explorer for Precise Attribution
Want to get really surgical? webpack-bundle-analyzer uses stat analysis, but source-map-explorer uses source maps for exact byte attribution — it tells you precisely which original file contributed which bytes.
npx source-map-explorer .next/static/chunks/main-abc123.js
The output shows a treemap where every byte is attributed to its original source file. This is more accurate than webpack-bundle-analyzer for identifying which of your source files are contributing the most code.
When to use which:
- webpack-bundle-analyzer: Bird's eye view of the entire bundle. Good for spotting large dependencies and duplicates.
- source-map-explorer: Surgical attribution. Good for understanding exactly which components or utilities are adding weight.
Barrel File Problem
We talked about this in the tree shaking chapter, but it's worth repeating because it bites so many teams: barrel files (index.ts that re-exports everything) can silently prevent tree shaking and balloon your bundle.
// src/utils/index.ts (barrel file)
export { formatDate } from './date';
export { formatCurrency } from './currency';
export { parseCSV } from './csv'; // imports 'papaparse' (50KB)
export { renderChart } from './chart'; // imports 'chart.js' (200KB)
export { validateEmail } from './validation';
// Your component — only needs formatDate
import { formatDate } from '@/utils';
You imported one 2KB function. But depending on the bundler configuration and whether the barrel file has side effects, you might have just pulled in papaparse and chart.js too — 250KB of code you'll never use.
// Fix: import directly from the source module
import { formatDate } from '@/utils/date';
Measuring Impact Before Adding Dependencies
This is a habit that separates senior engineers from everyone else: before adding any new dependency, check its cost.
bundlephobia.com
Check the size of any npm package before installing:
- Bundle size (minified + gzipped)
- Download time on slow 3G
- Whether it's tree-shakable
- Composition (what sub-dependencies does it pull in?)
Import Cost (VS Code Extension)
Shows the import cost inline in your editor:
import { debounce } from 'lodash'; // 72KB (whole lodash)
import debounce from 'lodash/debounce'; // 1.2KB (just this function)
import { debounce } from 'lodash-es'; // 1.5KB (tree-shakable)
Manual Check with size-limit
# Check the impact of adding a dependency to your bundle
npx size-limit --why
This shows a treemap of what changed in your bundle after adding the dependency.
The hidden cost of transitive dependencies
When you install a package, you also install its dependencies, and their dependencies. A seemingly small library can pull in a large dependency tree. For example, a "lightweight" form library might depend on a schema validation library that depends on a date parsing library. Use npm ls --all or the dependency graph in bundlephobia to understand the full cost. The direct dependency is 5KB, but the transitive tree might be 50KB.
A Bundle Audit Workflow
Put this on your calendar. Run this audit quarterly, or before any major release:
Step 1: Generate the treemap
ANALYZE=true npm run build
Step 2: List the top 10 largest modules
Sort by parsed size. For each one:
- Is it still used? (search codebase for imports)
- Can it be replaced with something smaller?
- Can it be lazy-loaded?
Step 3: Find duplicates
Look for the same library appearing twice
Run: npm dedupe && npm ls <library-name>
Step 4: Check barrel files
For each barrel import, verify tree shaking works:
- Import one item, build, check if only that item is included
- Add sideEffects: false where safe
Step 5: Audit polyfills
Check browserslist target vs included polyfills
Remove polyfills for features all target browsers support
Step 6: Set a budget
Record current total JS size
Set a budget 10% above current as a ceiling
Ratchet down after optimizations
- 1Run bundle analysis (ANALYZE=true npm run build) regularly — you can't optimize what you can't see.
- 2Look at parsed size for CPU cost and gzip size for network cost. Stat size is rarely useful.
- 3Check for duplicate dependencies with npm ls and fix with npm dedupe or package.json overrides.
- 4Barrel files are bundle killers. Import directly from source modules, not barrel index files. Ensure sideEffects: false in package.json.
- 5Check bundlephobia.com before adding any new dependency. Compare alternatives. Prefer built-in APIs (Intl, crypto, structuredClone) over libraries.
- 6Audit the bundle quarterly: top 10 largest modules, duplicates, unused polyfills, barrel files.
- 7source-map-explorer gives precise byte attribution from source maps. webpack-bundle-analyzer gives a bird's eye view.
Q: Your Next.js app's client bundle is 600KB compressed. The team says 'we don't know why it's so large.' Walk me through your investigation.
A strong answer: First, generate the treemap with ANALYZE=true npm run build. Sort by gzip size to find the top offenders. Common findings: full lodash instead of individual imports (72KB → 2KB), moment.js replaceable with dayjs or Intl (72KB → 0-2KB), duplicate dependencies (npm dedupe), oversized icon libraries (import individual icons, not the whole set), unused polyfills from a loose browserslist. Then check barrel files — import from specific modules instead of barrel index files. Check for accidental client-side inclusion of server-only code (a 'use client' component importing a large server utility). After fixing, set a performance budget at the new size + 10% to prevent regression.