Skip to content
Chapter 94Lesson 4

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.

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.

client bundle — area is bytes
react-dom + next runtime
~142 KB · the floor
shared chunk
~48 KB · every route
recharts
~178 KB · whole package
/dashboard chunk
~64 KB
date-fns
~22 KB · v3
date-fns
~22 KB · v2
Start with the biggest tile. Here it's the React + Next runtime, the floor every app pays, not a target. Note it and move on.
client bundle — area is bytes
react-dom + next runtime
~142 KB · the floor
shared chunk
~48 KB · every route
recharts
~178 KB · whole package
/dashboard chunk
~64 KB
date-fns
~22 KB · v3
date-fns
~22 KB · v2
Each route gets its own chunk. A fat one usually means a heavy interactive component lives on that page: a chart, an editor, a map.
client bundle — area is bytes
react-dom + next runtime
~142 KB · the floor
shared chunk
~48 KB · every route
recharts
~178 KB · whole package
/dashboard chunk
~64 KB
date-fns
~22 KB · v3
date-fns
~22 KB · v2
The tile you didn't expect is the lead. A charting library this large, on a page that barely charts, is a barrel import dragging the whole package in.
client bundle — area is bytes
react-dom + next runtime
~142 KB · the floor
shared chunk
~48 KB · every route
recharts
~178 KB · whole package
/dashboard chunk
~64 KB
date-fns
~22 KB · v3
date-fns
~22 KB · v2
The same library as two tiles means two copies in the dependency tree, different versions, both shipped. The browser downloads it twice: pure waste.
client bundle — area is bytes
react-dom + next runtime
~142 KB · the floor
shared chunk
~48 KB · every route
recharts
~178 KB · whole package
/dashboard chunk
~64 KB
date-fns
~22 KB · v3
date-fns
~22 KB · v2

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.

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.

Terminal window
# Build the production bundle and open an interactive treemap
pnpm next experimental-analyze
# Skip the interactive view and write a static report to disk
pnpm next experimental-analyze --output

The 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:

Terminal window
cp -r .next/diagnostics/analyze ./analyze-before

Now 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.

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.

  1. 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.

  2. 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 meet dynamic() later; for now it’s just the move to reach for.)

  3. 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.

  4. 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.

You found an oversized tile. Now what?

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.

Before
full barrel import
~600 KB
After
optimizePackageImports
~30 KB

The icon-library tile collapsing after optimizePackageImports. The shape is real; the exact bytes are illustrative.

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.

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.