Skip to content
Chapter 94Lesson 4

Reading the bundle treemap

Read your production JavaScript bundle with the built-in Turbopack analyzer, using a fixed set of scan passes to attribute weight to the exact module and import that caused it.

Here is a situation you will eventually find yourself in. INP is creeping up in Speed Insights, the production build feels heavier with every release, and yet every pull request that landed this month looks clean: a feature here, a refactor there, nothing that obviously added 200KB to every page. Nobody on the team can point at the dependency responsible, because “the bundle got bigger” is a feeling, not a line you can put your finger on.

This lesson gives you the instrument that turns that feeling into an attribution. You’ll learn to read a treemap of your production bundle: four scan passes that find the bytes that shouldn’t be there, and a triage tree that maps each finding to a fix. In the lesson on the Core Web Vitals you saw that INP rides on client JavaScript weight, and the previous two lessons named two ways that weight leaks: oversized images and barrel imports. The treemap is how you see and measure those leaks. The barrel fix you wrote in the lesson on the barrel-export trap is also where this pays off, because the treemap is how you confirm the chunk actually shrank.

Before reaching for the tool, understand the picture it draws, because reading the picture well matters far more than which button you click.

A bundle treemap is a set of nested rectangles where area is bytes. The whole production bundle is the canvas. It’s sliced into routes, each route into chunks, and each chunk into the individual modules that make it up. A module that fills a quarter of the picture is a quarter of your bytes. That single equation, area is weight, is the entire reading skill. Everything else is learning where to look.

Three rules turn that equation into a discipline. Each one is a place beginners reliably go wrong, so treat them as traps to avoid rather than trivia.

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 in raw form and shrink dramatically once gzip compresses it. When the tool offers more than one size view, read the one closest to what the browser downloads: the compressed or transfer figure.

Second, filter to the client. The analyzer shows two worlds. Client modules are the JavaScript the browser downloads, which is what drives INP. Server modules are the Node bundle that runs on the server. For a performance-vigilance pass aimed at interaction latency, the client side is the one that matters. The server view has its own use, since it tells you how heavy your serverless functions are, which affects cold starts, but that’s a secondary question. Filter to the client first.

Third, the framework runtime is the floor. The single biggest legitimate tile is React plus Next’s runtime. Every app pays it, so it is the baseline, not the optimization target. It’s tempting to see the largest rectangle, assume biggest equals problem, and spend an afternoon trying to shave a framework you can’t shave. The floor stays the floor; leave it alone.

Hold those three rules in mind while you look at a treemap for the first time. Here is a representative one, walked 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 shown as two tiles means two copies in the dependency tree, different versions, both shipped. The browser downloads it twice, which is 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, because every page pays it, so watch it for creep release over release. This treemap is illustrative: area is bytes, and your real one comes from next experimental-analyze.

You’ll meet this exact picture again in a moment, because the four scan passes each map to one of the tiles you just saw highlighted. Learn the picture here, then learn the passes against it next.

The choice of tool comes before any install, and here it’s easy. Turbopack is the Next.js 16 builder, so the analyzer you want is the one that understands the real Turbopack build. That’s the built-in Turbopack analyzer: no plugin to add, no next.config.ts to touch. It’s 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 builds the production bundle and opens an interactive treemap in your browser. Note the word production. The analyzer reads a production build on purpose, because the dev bundle is intentionally unoptimized and unsplit, so reading it tells you nothing about what ships. This is the same reason the optimizePackageImports rewrite from the previous lesson only runs in production: never trust a dev bundle for size.

The second command, with --output, skips the interactive view and writes a static copy of the report to .next/diagnostics/analyze. That’s the form you can keep. Copy it aside so you have a baseline to compare against after a fix:

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

Now run a fix, re-run the analyzer, and diff the two. That before-and-after copy is also the foundation of the CI-artifact pattern at the end of the lesson.

Inside the interactive view, four controls matter, because the scan passes use them: filter by route, filter by environment (client or server), filter by type (JS, CSS, JSON), and search by file. The one feature that turns guessing into diagnosis is this: click a module and 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, that import chain answers “why is this here?” by tracing the byte cost back to the line that caused it.

One more thing to clear up, because it trips up people following older tutorials. 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. If a guide tells you to read that build table, the guide predates version 16, so don’t go looking for it and don’t think your setup is broken. Your smell test now comes from elsewhere: real-user INP in Speed Insights, and the total-JavaScript number Lighthouse reports (the subject of the next lesson, on Lighthouse as the pre-launch gate). The treemap is what you open after the smell test trips. It’s the diagnosis, not the alarm.

A treemap rewards a fixed reading order. Rather than wandering it hoping a problem jumps out, you run four passes every time, in the same order. Each pass maps to one of the tiles you saw highlighted in the figure above: a thing to look at, what it means, and where it points you 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 that’s larger than it should be, such as a date library, a charting library, or an icon set pulled in whole, that’s your lead. Click it, read the import chain, and find the exact line that added it.

  2. Per-route weight: which routes carry heavy client chunks? Filter by route. A route with 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 doesn’t actually need interactivity, or code-split it with dynamic() if it sits below the fold or only appears after a click. (The mechanics of dynamic() come later in the course; for now, just know it’s the move to reach for.)

  3. Duplicate dependency: the same library twice. Scan for one package name showing up as two separate tiles, often as two versions. That means two copies of the same library in your dependency tree, usually from a peer-dependency mismatch. It’s pure waste, because 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 a global provider. It should stay close to 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 kind of bloat, because every single route pays for it.

Step back and notice the shape of those four. 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 the worse of the two, precisely because every route pays it, so when a shared chunk grows it deserves your attention first.

Running the same four passes every time is what makes the pass-to-meaning mapping automatic. Drill it once:

Each observation came from one of the four scan passes. Sort each finding into the pass that surfaced it. Drag each item into the bucket it belongs to, then press Check.

Biggest tile The single largest module
Per-route chunk Weight concentrated on one route
Duplicate dep The same library shipped twice
Shared chunk Loaded on every route
A charting library is the largest module on the whole canvas.
Only /dashboard ships a 200KB rich-text editor chunk.
date-fns shows up as two tiles under different versions.
The chunk on every route grew 80KB right after an analytics provider was added to the root layout.

A finding is only useful if you know what to do with it. The four passes give you four kinds of finding, and this is the order an experienced engineer triages them in. What matters isn’t any single leaf; it’s running the questions in the right sequence so the cheapest, highest-leverage fix surfaces first. Walk the tree against whatever surprising tile you just found.

You found an oversized tile. Now what?

That barrel leaf is where the previous lesson pays off. You added optimizePackageImports for an icon library, and the treemap is how you prove it worked. Run experimental-analyze before the change, run it again after, and watch the tile collapse.

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

The icon-library tile you’d watch shrink after optimizePackageImports. The shape is real, but the exact bytes are illustrative; run experimental-analyze before and after on your own build to read the true numbers.

An instrument is only safe to trust once you know its blind spots. The treemap has two, and both matter for the INP thread running through this chapter, because both can produce a slow page that looks innocent on the treemap.

The first blind spot is runtime cost. The treemap measures static bytes, how much JavaScript ships. It says nothing about what that JavaScript does once it runs. A small bundle can still produce terrible INP: a synchronous JSON.parse of a large payload, an O(n²) loop in a click handler, a client tree that re-renders far more than it should. None of that shows up as area on a treemap, because it isn’t bytes; it’s work. The instrument for runtime work is the Chrome DevTools Performance panel, not the analyzer. Remember that bundle size and INP are correlated, not identical: a tiny bundle does not guarantee a fast interaction.

The second blind spot is third-party scripts. A <script> loaded through next/script, such as analytics, a chat widget, or a tag manager, isn’t part of your bundle, so it never appears on the treemap at all. Yet heavy third parties routinely outweigh a team’s own code on the wire. The treemap will happily show you a lean, well-triaged bundle while a tag manager quietly downloads more JavaScript than everything you wrote. The Network panel is where you see those, and the mitigations (deferring them with a lazy loading strategy, gating them behind consent) live in other lessons. The point here is narrower: the analyzer will never warn you about a third-party script, so you have to go look.

The treemap is a map of one thing, not a final verdict. It answers “what static bytes did I ship, and why.” The Performance panel answers “what does my code do at runtime.” Speed Insights answers “what do real users actually experience.” Each instrument owns one question, so reach for the right one.

The most common way teams misuse the treemap is to run it once, breathe a sigh of relief, and never run it again. Bundles drift release over release, a dependency bump here, a convenience import there, and that drift is exactly the regression this whole chapter exists to catch. So treat the bundle audit as a habit, not a launch task. Two cadences cover it.

The first is the per-dependency-change pull request. Any PR that adds a dependency, bumps one, or drops in a heavy interactive component gets an analyzer pass. The cheap, structural version needs no bot at all: run next experimental-analyze --output, attach the report to the PR (or upload it as a CI artifact), 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. A formal GitHub Action for this exists, but the well-known one (hashicorp/nextjs-bundle-analysis) is built around Webpack and the old build table, so on a Turbopack project the honest recommendation is the --output artifact plus a reviewer diff. Wiring it into CI properly is a sibling to the Lighthouse gate in the next lesson and builds on the GitHub Actions primitives taught later in the course; for now, just know the shape.

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. That’s the bundle-axis counterpart to the broader pre-launch audit in the next lesson on Lighthouse as the pre-launch gate.

One sentence ties the chapter together: field data tells you that the bundle regressed, and the treemap tells you what did.