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:
- "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). - 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.
- The gap between routes —
/aboutat 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
/aboutfrom Google downloads 189 KB, not 1.1 KB.
The analyzer¶
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withBundleAnalyzer({
// …rest of config
});
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:
- How big is the shared chunk, and why? If shared > 150 KB, find the root client boundary.
- 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?
- Is there a barrel file in the import path?
@aurora/ui,lucide-react,date-fns,lodash— barrels defeat tree‑shaking in many configurations. - 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. - Is any server‑only code in the client bundle? Search the treemap for database drivers, sanitizers, markdown parsers.
- Is anything duplicated? Two React versions, two copies of a util package.
- 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¶
- Run
ANALYZE=true npm run build. Screenshotclient.htmlat gzipped mode. - Record First Load JS per route and the shared chunk size. Compare to your budgets (1.5).
- Build the attribution table — every item over 15 KB gets a row with an owner and an action.
- Run Coverage on your three main page types. Note unused percentages per chunk.
- Check for duplicates:
npm ls react react-domand look for multiple versions of anything big. - Check
browserslist. If it's targeting anything pre‑2022, measure the polyfill cost of tightening it. - 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
-
browserslisttargets only browsers you support - PR‑level size diff comment automated
- Shared chunk size tracked over time on a dashboard
Next: 4.2 Code splitting