Skip to content

4.1 — Bundle analysis workflow

Module 4 · Lesson 1 · 🟡 Intermediate · ~35 min

What you'll learn

  • How to read the Next.js build output and the bundle analyzer without getting lost
  • Attributing every kilobyte to a team, a feature, and a decision
  • Tracking bundle size over time so regressions are caught on the PR that caused them
  • The specific questions to ask of a 700 KB commerce bundle

Start with the build output

Before any tooling, next build already tells you most of what you need:

Route (app)                               Size     First Load JS
┌ ○ /                                     2.14 kB         198 kB
├ ● /c/[slug]                             8.31 kB         241 kB
├ ƒ /p/[slug]                            14.20 kB         267 kB
├ ƒ /cart                                 6.02 kB         212 kB
├ ƒ /checkout                            31.40 kB         289 kB
└ ○ /about                                1.10 kB         189 kB
+ First Load JS shared by all             186 kB
  ├ chunks/framework-8a2f.js               44.8 kB
  ├ chunks/main-3c91.js                    32.1 kB
  ├ chunks/shared-b7d2.js                  94.6 kB      ← 💀 investigate this
  └ other shared chunks (all)              14.5 kB

Read it in this order:

  1. "First Load JS shared by all" — 186 KB here means every route, including /about, pays 186 KB. This is almost always the biggest opportunity, and it's almost always caused by a 'use client' provider near the root (3.2).
  2. The route‑specific "Size" column — code unique to that route. Checkout's 31.4 KB is plausible (payment forms); a 14 KB PDP is fine.
  3. The gap between routes/about at 189 KB when it needs ~5 KB tells you the shared chunk is doing work no static page should pay for.

First Load JS is the number that matters, not route Size. A user landing on /about from Google downloads 189 KB, not 1.1 KB.


The analyzer

npm i -D @next/bundle-analyzer
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer';

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
});

export default withBundleAnalyzer({
  // …rest of config
});
ANALYZE=true npm run build
# Opens client.html, nodejs.html, edge.html

Read client.html only for JS payload work — nodejs.html is server code and doesn't ship to users. This trips people up constantly; they optimize a server chunk and see no change.

How to actually read the treemap

The treemap is overwhelming at first. Work through it with these four questions:

1. What's the biggest rectangle in node_modules? Usually one of: a date library, an icon library, an i18n runtime, a chart library, a carousel, or a polyfill bundle. Each has a known replacement (4.3).

2. Is anything in here twice? Two versions of the same package is a classic monorepo problem. Search the treemap for a package name and see if it appears in multiple chunks or at multiple versions.

npm ls react date-fns @aurora/ui   # find duplicate versions
npx npm-why lodash                  # who is pulling this in?

3. Is anything here that should be server‑only? A markdown renderer, an HTML sanitizer, a SQL builder, a large validation schema. If you find one, you have a boundary bug and possibly a security bug.

4. Which of your own directories is biggest? Your own code is usually 20–40% of the bundle. If your components/ rectangle is larger than node_modules, the problem is boundary placement, not dependencies.

Toggle the size modes

The analyzer shows three sizes; use the right one:

Mode Meaning Use for
Stat Size before minification Ignore — misleading
Parsed After minification Main‑thread cost (parse/compile scales with this)
Gzipped After compression Network cost, and what your budget is in

Optimize against Gzipped for budgets and Parsed for INP work. A library that gzips well but parses to 800 KB still costs main‑thread time.


Attribution: from bytes to owners

A treemap tells you what. To get action you need who and why. Build the attribution table:

Chunk contents KB (gz) Owner Why it's there Action
lucide-react (barrel) 92 Design system Icon imports via barrel Deep imports → 4 KB
date-fns (all locales) 61 Checkout squad Delivery date formatting Intl.DateTimeFormat → 0 KB
@aurora/ui (barrel) 74 Design system export * index Deep imports → 22 KB
react-image-gallery 38 PDP squad Product gallery Custom, CSS scroll‑snap → 3 KB
Analytics SDK 34 Growth Client‑side tracking Server‑side events → 0 KB
zod schemas (shared) 29 Platform Validation shared client/server Server‑only for most schemas → 8 KB
i18n runtime + all locales 47 Platform Client translation Server‑render text → 6 KB
Own components 118 Various Boundary work (3.2)

The "Owner" column is what turns this from a report into scheduled work. Without it, the analysis sits in a doc for a year.

Find who imports what:

# Every import of a suspicious package, with file and line
rg -n "from ['\"]date-fns" app components lib

# Which files are marked client (they're the only ones that can contribute to the bundle)
rg -l "^'use client'" app components

# Cross-reference: a heavy import inside a client file is a real cost;
# the same import in a server file is free
rg -l "from ['\"]date-fns" $(rg -l "^'use client'" app components)

Per‑route attribution

The shared chunk hides which route needs what. To see route‑specific cost, compare builds:

#!/usr/bin/env bash
# scripts/route-size-diff.sh — measure a dependency's real cost on each route
set -euo pipefail

npm run build > /tmp/before.txt
# Apply your change (remove a dep, add a dynamic import, etc.)
git stash pop 2>/dev/null || true
npm run build > /tmp/after.txt

diff <(grep -E '^[├└┌]' /tmp/before.txt) <(grep -E '^[├└┌]' /tmp/after.txt) || true

More precisely, use the build manifests, which map routes to the exact chunk files:

// scripts/analyze-routes.mjs
import { readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

const manifest = JSON.parse(
  readFileSync('.next/app-build-manifest.json', 'utf8'),
);

const rows = [];
for (const [route, files] of Object.entries(manifest.pages)) {
  const jsFiles = files.filter((f) => f.endsWith('.js'));
  const bytes = jsFiles.reduce(
    (sum, f) => sum + statSync(join('.next', f)).size,
    0,
  );
  rows.push({
    route,
    chunks: jsFiles.length,
    kb: +(bytes / 1024).toFixed(1),
  });
}

rows.sort((a, b) => b.kb - a.kb);
console.table(rows);

(This reports uncompressed size — useful for relative comparison and for parse cost. For budget enforcement against gzipped numbers, see examples/ci/bundle-budget.mjs.)


Coverage: what's downloaded but unused

Bundle size tells you what shipped. Coverage tells you what ran.

DevTools → Ctrl+Shift+P → "Show Coverage" → reload. You'll typically see 40–70% of JS unused on a commerce page load. Not all of that is waste — some is code for interactions the user hasn't performed yet — but it points at splitting opportunities.

Coverage on Aurora PDP (before)
main-3c91.js         32.1 KB    18% unused
shared-b7d2.js       94.6 KB    71% unused   ← the cart store, i18n, analytics
                                                on a page that uses none of it initially
p-slug-4a2e.js       14.2 KB    44% unused   ← size guide + configurator code

Interpreting it:

  • High unused % in a shared chunk → boundary or provider problem (3.2)
  • High unused % in a route chunk → splitting opportunity (4.2)
  • High unused % in a vendor chunk → the library isn't tree‑shaking (4.3)

Tracking over time

A one‑off analysis decays in six weeks. Make size a tracked metric.

# .github/workflows/bundle-size.yml
name: Bundle size

on: pull_request

jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }

      - run: npm ci

      # Build the base branch for a true comparison
      - name: Build base
        run: |
          git checkout ${{ github.event.pull_request.base.sha }} -- . 2>/dev/null || true
          npm ci --prefer-offline
          npm run build
          node scripts/measure-bundle.mjs > /tmp/base.json
          git checkout ${{ github.sha }} -- .

      - name: Build PR
        run: |
          npm ci --prefer-offline
          npm run build
          node scripts/measure-bundle.mjs > /tmp/pr.json

      - name: Compare and comment
        run: node scripts/compare-bundle.mjs /tmp/base.json /tmp/pr.json
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

The PR comment should be specific enough to act on without opening the analyzer:

📦 Bundle impact

Route            Base      PR        Δ          Budget    Status
/                198.0 KB  198.0 KB  —          200 KB    ✅
/p/[slug]        267.1 KB  294.7 KB  +27.6 KB   260 KB    ❌ over by 34.7 KB
/checkout        289.0 KB  289.0 KB  —          180 KB    ⚠️ pre-existing

New modules in /p/[slug]:
  + react-image-gallery@1.3.0    18.2 KB   components/product-gallery.tsx:4
  + date-fns/locale (barrel)      7.1 KB   components/review-date.tsx:2
  + prop-types                    2.3 KB   (transitive, via react-image-gallery)

💡 react-image-gallery is below the fold on this route — consider next/dynamic.
💡 date-fns: import the specific locale, not the barrel.

Also track the aggregate over time so you can see drift:

Shared First Load JS, last 90 days
186 KB ┤                                    ╭──
       │                          ╭─────────╯
170 KB ┤              ╭───────────╯
       │      ╭───────╯
154 KB ┼──────╯
       └────┬────┬────┬────┬────┬────┬────┬────
          -90d           -45d            today

That upward staircase is what budgets exist to prevent. Each step is a PR that added 8 KB and nobody noticed.


The questions to ask of a large bundle

Work through these in order on any commerce app over 400 KB:

  1. How big is the shared chunk, and why? If shared > 150 KB, find the root client boundary.
  2. Which node_modules packages are over 20 KB? For each: is it needed on first load? Is there a lighter alternative? Can it be server‑only?
  3. Is there a barrel file in the import path? @aurora/ui, lucide-react, date-fns, lodash — barrels defeat tree‑shaking in many configurations.
  4. Are polyfills being shipped to modern browsers? Check your browserslist. Targeting IE‑era browsers can add 40–80 KB of transpilation and polyfills for zero users.
  5. Is any server‑only code in the client bundle? Search the treemap for database drivers, sanitizers, markdown parsers.
  6. Is anything duplicated? Two React versions, two copies of a util package.
  7. What's the parse time, not just the bytes? A 40 KB library that parses to 400 KB costs more main‑thread time than a 60 KB one that parses to 200 KB.
// package.json — check your browserslist. This targets ~95% of users
// with no legacy transpilation.
{
  "browserslist": [
    "chrome >= 111",
    "safari >= 16.4",
    "firefox >= 111",
    "edge >= 111",
    "not dead"
  ]
}

Common mistakes

Mistake Consequence
Reading nodejs.html instead of client.html Optimizing code that never ships to users
Optimizing route Size instead of First Load JS Missing the 186 KB everyone pays
Using "Stat" size Numbers that don't correspond to anything real
One‑off analysis with no tracking Regression within two months
No owner column Report with no follow‑through
Ignoring parse time Bytes aren't the only cost
Analyzing a dev build Dev bundles are unminified and unrepresentative
Not diffing against the base branch "It was already big" instead of "this PR added 27 KB"

Lab 4.1 — Full bundle audit

  1. Run ANALYZE=true npm run build. Screenshot client.html at gzipped mode.
  2. Record First Load JS per route and the shared chunk size. Compare to your budgets (1.5).
  3. Build the attribution table — every item over 15 KB gets a row with an owner and an action.
  4. Run Coverage on your three main page types. Note unused percentages per chunk.
  5. Check for duplicates: npm ls react react-dom and look for multiple versions of anything big.
  6. Check browserslist. If it's targeting anything pre‑2022, measure the polyfill cost of tightening it.
  7. Set up the PR size comparison workflow. This is the item that makes the rest stick.

Checklist

  • First Load JS per route recorded and compared to budget
  • Shared chunk understood line by line
  • Attribution table with owners and actions
  • Coverage measured per page type
  • No duplicate package versions
  • No server‑only code in the client bundle
  • browserslist targets only browsers you support
  • PR‑level size diff comment automated
  • Shared chunk size tracked over time on a dashboard

Next: 4.2 Code splitting