skip to content

A Next.js app's First Load JS has grown by roughly 150 kB over a sprint and nobody knows which change did it. How would you use @next/bundle-analyzer to find the cause, and what do you typically find?

level: seniorimportance: must knowfreq 58%

answer

  1. localise before you investigate
  2. one flag, one build, three reports
  3. biggest unexpected block, not biggest block
  4. the treemap names the module, not the importer
  5. the fix depends on why it is there

basics

~20 s

Wire @next/bundle-analyzer into next.config, build with ANALYZE=true, and read the client treemap: find the largest unexpected block, then trace which module pulled it in. The usual culprits are a barrel import, a heavy dependency added to a shared client component, and a library dragged across the client boundary.

solid answer

~60 s

First localise it from the build output: did the shared baseline grow, or one route? That already halves the search space. Then wrap the config with `@next/bundle-analyzer` and run `ANALYZE=true next build`, which writes treemap reports for the client, Node, and Edge bundles. Read the client one, since that is what users download. Find the biggest block you did not expect, and then answer the real question — *what imports it*. Usually you can see it from the chunk it landed in and a grep for the package name. If the regression is recent, bisect the build across the sprint's commits; the analyzer names the module, the bisect names the commit. What I typically find: a barrel import such as `import { X } from 'some-ui-kit'` dragging in far more than X, an icon or date library imported wholesale, a heavy dependency added to a component inside the root layout so every route pays it, or a module that used to be server-only being pulled into a Client Component. Fixes follow the cause: deep imports, `next/dynamic`, or moving the work back to the server.

code

bash · 6 lines
bash
# One-off investigation run: emits client, nodejs and edge treemaps
ANALYZE=true npx next build

# Bisect the sprint using the build output as the test
git bisect start HEAD HEAD~30
git bisect run sh -c 'npx next build | grep -q "shared by all 9[0-9] kB"'

go deeper

for a junior

Know that @next/bundle-analyzer exists, that it is enabled through next.config and an environment variable on a build, and that it shows a treemap of what ended up in each chunk.

for a middle

Explain the workflow end to end: narrow with the build output, run the analyzer, read the client report, then trace which module imports the heavy one. Name barrel imports and wholesale library imports as common causes.

for a senior

Demonstrate method over tool trivia — bisecting on a reproducible build number, distinguishing shared-baseline from leaf-route regressions, matching the fix to the cause, and verifying against the before numbers.

for a principal

Own the prevention side: budgets enforced in CI, a dependency-review habit, and a shared understanding of which entry points matter enough to defend. Decide when a regression is worth engineering time and when it is noise.

## Step 1 — read the build output before you open anything The route table already narrows the search. If the `First Load JS shared by all` line grew, the new code is reachable from the root layout, a global provider, or a module common enough to be hoisted — that affects every entry point and is the highest priority. If a single route grew, you only have that route's import graph to search. Note the exact numbers; you will compare against them after the fix. ## Step 2 — wire up the analyzer `@next/bundle-analyzer` is an official plugin that wraps your config and, when enabled, emits interactive treemaps of the built bundles. ```javascript // next.config.mjs import bundleAnalyzer from '@next/bundle-analyzer' const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true', }) export default withBundleAnalyzer({}) ``` ```bash ANALYZE=true npx next build ``` Gating it behind an environment variable matters: you want normal builds untouched, and one flag to turn the investigation on. The run produces separate reports for the client, Node, and Edge bundles. **Read the client report** — the other two describe code that never reaches a browser and are irrelevant to First Load JS. ## Step 3 — read the treemap the right way A treemap gives area to size. The instinct is to look at the biggest rectangle; the discipline is to look at the biggest rectangle *you did not expect*. React and the framework runtime are supposed to be large. Three readings are worth taking: - **Which chunk is it in?** A heavy module in the shared chunk is a much bigger problem than the same module in one route's chunk. - **How much of the package is present?** Seeing forty files of a library you use one function from is the signature of a barrel or wholesale import. - **Is it duplicated?** The same library appearing under two paths usually means two versions resolved, or one copy bundled and another pulled in transitively. ## Step 4 — trace the import The analyzer tells you *what* is there. You still have to establish *why*. Practical moves, cheapest first: grep the source for the package name; check whether the importer sits inside a `'use client'` module or is imported by one; and if the answer is still unclear, bisect. Because you have a build-output number that reproduces deterministically, `git bisect` over the sprint's commits with "does the shared line exceed X" as the test is fast and conclusive. ## What you usually find **A barrel import.** `import { Button } from 'ui-kit'` where `ui-kit`'s entry point re-exports two hundred modules. Whether the unused ones are dropped depends on how the package is authored and published; when they are not, one component costs you the library. Fixes: import the deep path the package documents, or list the package under `experimental.optimizePackageImports` in `next.config`, which rewrites such imports to the individual modules. **Wholesale imports of a big utility or icon library.** `import * as Icons from '…'` or a date library with all its locales. The fix is importing only what is used, or replacing the dependency. **A heavy dependency added to a globally rendered component.** Someone added a rich component to the root layout or a global provider; now every route carries it. The fix is often to render it where it is actually used, or to lazy-load it. **Code crossing to the client that used to be server-side.** A module that was only used in server rendering starts being imported by a Client Component, so it now ships to the browser as well. The fix is to move the work back, or pass the computed result instead of the library. **A duplicated dependency.** Two versions of the same package resolved in the tree. The fix lives in the package manager — dedupe or align the version range. ## Step 5 — verify and defend Re-run `next build` and compare against the numbers you wrote down. The regression is fixed when the table says so, not when the diff looks right. Then stop it happening again. Options, in rough order of cost: print the build output in CI so the numbers are visible on every pull request; fail the build when First Load JS crosses a budget you chose; or lint against the specific import shape that caused it. Whichever you pick, the point is that a size regression should be caught by a build rather than noticed a sprint later by whoever happens to look. ## What the analyzer will not tell you It reports what was bundled, not what was executed or what the user experienced. A 150 kB chunk that only loads behind an admin route matters far less than 20 kB added to the shared baseline. Always bring the finding back to the question that started the investigation: which users, on which entry point, download this before the page can render.

  • The analyzer shows a UI library's entire module list in the client chunk although you import one component. What is happening, and what are your options?
    A barrel entry point re-exports everything, and something about how the package is authored or consumed prevents the unused exports being dropped. Options: import the documented deep path for the one component, add the package to `experimental.optimizePackageImports` so Next rewrites the import to individual modules, or replace the dependency if it is unfixable.
  • Why read the client report rather than the Node or Edge one when chasing First Load JS?
    Because First Load JS counts only what a browser downloads. The Node and Edge reports describe the server bundles — useful for cold-start or Edge size limits, but a large module there costs users nothing in download. Reading the wrong report is a common way to spend an afternoon on a non-problem.
  • How would you stop a regression like this from going unnoticed for another sprint?
    Make the number visible on every change: print the route table in CI, and ideally fail when First Load JS or the shared baseline crosses an agreed budget. A hard budget forces the conversation at review time, when the person who added the dependency is still in context, instead of during an archaeology session later.
  • The heavy module turns out to sit in one admin route's chunk, not the shared one. Does that change your priority?
    Substantially. Only users who land on that route pay it, and admin users are typically few and on better hardware. I would record it, fix it if cheap, and spend the effort on anything touching the shared baseline or a high-traffic entry point instead. Bundle work should be ranked by users affected, not by kilobytes.

saying these in an interview costs you the question

  • Opens the analyzer before checking which routes actually regressed
  • Reads the Node bundle report when chasing client download size
  • Assumes the biggest rectangle is automatically the problem
  • Adds next/dynamic everywhere instead of finding the importer
  • Declares it fixed without re-running the build to compare numbers

context