Skip to content

Bundle Analysis

advanced16 min read

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.

Mental Model

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.

Quiz
A module shows 200KB stat size, 80KB parsed size, and 25KB gzip size. Which number matters most for a user on a slow 3G connection?

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"
  }
}
Common Trap

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:

  1. Is it even used? Maybe it's a leftover from a feature someone removed six months ago
  2. Is the whole thing needed? Maybe you use one function from a 72KB library
  3. Is there a lighter alternative? dayjs (2KB) vs moment (72KB), clsx (1KB) vs classnames (3KB)
  4. Can it be lazy-loaded? Charts, editors, and rich text formatting can load on demand
Quiz
Your bundle analyzer shows core-js at 65KB. Your app targets modern browsers (last 2 Chrome/Firefox/Safari versions). What should you do?

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';
Quiz
A barrel file re-exports 20 utilities from 20 separate files. You import one utility from the barrel. Your bundle shows all 20 utilities included. Why?

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
Quiz
After a bundle audit, you find that 'moment.js' (72KB) is used in exactly one component for a single format() call. What's the best fix?
Key Rules
  1. 1Run bundle analysis (ANALYZE=true npm run build) regularly — you can't optimize what you can't see.
  2. 2Look at parsed size for CPU cost and gzip size for network cost. Stat size is rarely useful.
  3. 3Check for duplicate dependencies with npm ls and fix with npm dedupe or package.json overrides.
  4. 4Barrel files are bundle killers. Import directly from source modules, not barrel index files. Ensure sideEffects: false in package.json.
  5. 5Check bundlephobia.com before adding any new dependency. Compare alternatives. Prefer built-in APIs (Intl, crypto, structuredClone) over libraries.
  6. 6Audit the bundle quarterly: top 10 largest modules, duplicates, unused polyfills, barrel files.
  7. 7source-map-explorer gives precise byte attribution from source maps. webpack-bundle-analyzer gives a bird's eye view.
Interview Question

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.