Reading the bundle treemap
Read your production JavaScript bundle with the built-in Turbopack analyzer, attributing weight to the module and import that caused it.
Picture this scenario. INP is creeping up in Speed Insights and the production build feels heavier every release, yet every pull request this month looked clean: a feature here, a refactor there, nothing that obviously added 200KB. Nobody can name the dependency responsible, because “the bundle got bigger” is a feeling, not a line you can point at.
This lesson turns that feeling into an attribution. You’ll read a treemap of your production bundle in four scan passes, find the bytes that shouldn’t be there, and map each finding to a fix. You already know INP rides on client JavaScript weight, and that oversized images and barrel imports are two ways weight leaks in. The treemap is how you see those leaks, measure them, and confirm a fix shrank the chunk.
How a treemap encodes bytes
Section titled “How a treemap encodes bytes”Reading the picture matters more than which button you click, so start with the picture.
A bundle treemap is a set of nested rectangles where area is bytes. The whole production bundle is the canvas, sliced into routes, each route into chunks, each chunk into its modules. A module that fills a quarter of the picture is a quarter of your bytes. That equation, area is weight, is the whole reading skill; the rest is knowing where to look.
Three rules turn the equation into a discipline, and each is a place beginners go wrong.
First, read the transfer size, not the raw size. What hurts the user is the compressed bytes that travel over the wire, not the unminified source on disk. A library can look enormous raw and shrink dramatically once gzip compresses it. When the tool offers more than one view, read the one closest to what the browser downloads.
Second, filter to the client. The analyzer shows two worlds: client modules, the JavaScript the browser downloads that drives INP, and server modules, the Node bundle that runs on the server. For a pass aimed at interaction latency, filter to the client first. The server view tells you how heavy your serverless functions are, which affects cold starts, but that’s a separate question.
Third, the framework runtime is the floor. The single biggest legitimate tile is React plus Next’s runtime. Every app pays it, so it’s the baseline, not the optimization target. Don’t mistake the largest rectangle for the problem and spend an afternoon trying to shave a framework you can’t shave.
Hold those three rules in mind and walk a representative treemap tile by tile.
The chunk loaded on every route is the most expensive weight, since every page pays it, so watch it for creep release over release. This treemap is illustrative; your real one comes from next experimental-analyze.
You’ll see these same tiles next, where each maps to one of the four scan passes.
Running the Turbopack analyzer
Section titled “Running the Turbopack analyzer”Turbopack builds Next.js 16, so use the built-in analyzer that reads a real Turbopack build.
No plugin, no next.config.ts change, one command.
# Build the production bundle and open an interactive treemappnpm next experimental-analyze
# Skip the interactive view and write a static report to diskpnpm next experimental-analyze --outputThe first command reads a production build on purpose: the dev bundle is unoptimized and unsplit, so it tells you nothing about what ships. Never trust a dev bundle for size.
The second writes a static report to .next/diagnostics/analyze.
Copy it aside before a fix to keep a baseline to diff against:
cp -r .next/diagnostics/analyze ./analyze-beforeNow apply a fix, re-run the analyzer, and compare.
The interactive view offers four controls: filter by route, by environment (client or server), by type (JS, CSS, JSON), and search by file. What turns guessing into diagnosis is clicking a module: the analyzer shows its size and its import chain, the exact sequence of imports that pulled it into the bundle. When a tile surprises you, the import chain traces the byte cost back to the line that caused it.
Next.js 16 removed the per-route “First Load JS” table that next build used to print, because those numbers were inaccurate for Server Component apps.
Any guide that tells you to read that table predates version 16.
Your smell test now comes from real-user INP in Speed Insights and the total-JavaScript number Lighthouse reports.
Open the treemap after the smell test trips: it’s the diagnosis, not the alarm.
The four scan passes
Section titled “The four scan passes”Don’t wander a treemap hoping a problem jumps out. Run four passes in the same fixed order, each aimed at one tile: what to look at, what it means, and where it points next.
-
Biggest tile: expected or surprise? Find the single largest module. If it’s the framework runtime, that’s the floor, so leave it. If it’s a library you didn’t think you shipped, or a known-heavy one bigger than it should be, such as a date library, a charting library, or a whole icon set, that’s your lead. Click it, read the import chain, find the exact line that added it.
-
Per-route weight: which routes carry heavy client chunks? Filter by route. A fat client chunk almost always has a heavy interactive component on its leaves: a chart, a rich text editor, a map. The fix takes one of two shapes: move the work to the server if it needs no interactivity, or code-split it with
dynamic()if it sits below the fold or appears only after a click. (You’ll meetdynamic()later; for now it’s just the move to reach for.) -
Duplicate dependency: the same library twice. Scan for one package name showing up as two tiles, often two versions. That’s two copies of the library in your dependency tree, usually from a peer-dependency mismatch, so the browser downloads the same code twice. The fix isn’t in your code; it’s at the package manager, with
pnpm dedupe. -
Shared chunk: did the floor rise? Look at the chunk loaded on every route: the shared runtime plus anything imported by a root layout or global provider. It should stay near constant from release to release. If it grew, a heavy library landed on every page at once, typically because something got added to a top-level provider or
layout.tsx. This is the most expensive bloat, because every route pays for it.
These four split in two. Passes 1 and 2 find route-local bloat, weight that lives on one page; passes 3 and 4 find global, structural bloat, weight that lives everywhere. Global bloat is worse, so a shared chunk that grew deserves your attention first.
Deciding what to do with an oversized tile
Section titled “Deciding what to do with an oversized tile”A finding is only useful if you know what to do with it. The four passes give you four kinds of finding; run these questions in order and the cheapest, highest-leverage fix surfaces first. Walk the tree against whatever tile surprised you.
The framework runtime is the baseline every app pays, so there’s no win here. Note it and move on.
An unexpected heavy dependency is the highest-value fix on the board. Click the tile, read the import chain to the line that pulled it in, then delete that usage or swap in a lighter alternative.
Two copies are pure waste. Run pnpm dedupe, then audit the peer dependency that forced two versions so it doesn’t drift back.
This is the barrel-export trap from the previous lesson. Add the package to experimental.optimizePackageImports, then re-run the analyzer to confirm the tile shrank.
A heavy interactive component on one route. Render it on the server if it needs no interactivity, or load it lazily with dynamic() if it’s below the fold or behind a click.
Something pulled this into the root layout or a global provider, so every route pays. Find what imported it and move that import down to where it’s used.
The barrel leaf is where the previous lesson pays off.
You added optimizePackageImports for an icon library; the treemap proves it worked.
Run experimental-analyze before the change and after, and watch the tile collapse.
The icon-library tile collapsing after optimizePackageImports. The shape is real; the exact bytes are illustrative.
The treemap’s two blind spots
Section titled “The treemap’s two blind spots”Trust an instrument only once you know its blind spots. The treemap has two, and both can hide a slow page behind an innocent-looking map.
The first is runtime cost.
The treemap measures static bytes, how much JavaScript ships, not what that code does once it runs.
A small bundle can still produce terrible INP from a synchronous JSON.parse of a large payload, an O(n²) loop in a click handler, or a client tree that re-renders far more than it should.
None of that is bytes, so none of it shows up as area; bundle size and INP are correlated, not identical.
For runtime work, reach for the Chrome DevTools Performance panel.
The second is third-party scripts.
A <script> loaded through next/script, an analytics tag, a chat widget, a tag manager, isn’t part of your bundle, so it never appears on the treemap, yet such scripts routinely outweigh your own code on the wire.
The analyzer will show a lean bundle while a tag manager quietly downloads more JavaScript than everything you wrote.
You find those in the Network panel; deferring or gating them lives in other lessons.
The point here is narrow: the analyzer never warns you about a third-party script, so you have to go look.
Each instrument owns one question. The treemap answers what static bytes you shipped and why; the Performance panel, what your code does at runtime; Speed Insights, what real users experience.
Two cadences for ongoing bundle audits
Section titled “Two cadences for ongoing bundle audits”Teams misuse the treemap most often by running it once and never again. But bundles drift release over release, a dependency bump here, a convenience import there, and that drift is the regression this chapter exists to catch. Treat the audit as a habit on two cadences.
The first is the per-dependency-change pull request.
Any PR that adds, bumps, or drops a heavy dependency gets an analyzer pass.
No bot required: run next experimental-analyze --output, attach the report, and have the reviewer eyeball the diff against main.
A single line in your PR template, “ran the analyzer, here’s the diff,” covers the whole regression class.
Wiring this into CI builds on tooling taught later; for now, the artifact plus a reviewer diff is enough.
The second is the pre-launch deep pass. Once, before you ship, walk the treemap on your most important routes, the marketing page, the dashboard, the primary task screen. Run all four scan passes, triage each finding, fix it, and re-run to confirm.
One sentence ties the chapter together: field data tells you that the bundle regressed, the treemap tells you what did.