open atlas
↑ Back to track
Next.js, zero to senior NEXT · 05 · 03

Bundle analysis and budgets: first-load JS, barrel-file bloat, and CI gates

First-load JS is the number to own: barrel imports and use-client creep inflate shared chunks invisibly. Read the analyzer treemap, know the KB→main-thread-ms→INP/LCP chain, and gate per-route budgets in CI so a +300 KB icon import fails the PR, not the field data.

NEXT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

A two-line PR adds a checkmark icon to the site header: import { Check } from "@acme/icons" — the company’s internal icon package, a barrel file re-exporting 900 SVG components. The header is a Client Component in the root layout, so the import lands in the chunk every route shares. First-load JS goes from 128 KB to 431 KB. Nobody notices: the PR diff is two lines, CI is green, the reviewer’s M3 MacBook renders it instantly. The damage surfaces over the next month as field data accumulates — INP on mid-range Android slides from 180 ms to 320 ms, mobile bounce rate creeps up 6%, and an SEO report flags degraded Core Web Vitals across every page of the site. The eventual investigation is one ANALYZE=true build: the treemap shows the entire icon set sitting in the shared chunk, dwarfing React itself. The fix is one line. The lesson is the system that was missing: nobody owned the number, so the number moved. Bundles don’t regress in big dramatic commits — they regress two lines at a time, and only a CI budget notices two lines.

Reading the numbers: first-load JS and the analyzer treemap

Every next build prints the table that matters: per route, its own size and its First Load JS — the total JavaScript a cold visitor downloads to render that route, which is the route’s own chunk plus the shared chunks (framework, your common code) listed at the bottom as “First Load JS shared by all”. A fresh create-next-app starts at roughly 100 KB gzipped of shared first-load; that’s your floor. The route column going up hurts one page — the shared number going up hurts every page at once, which is why the Hook’s icon import was a site-wide incident, not a header tweak.

When the table tells you that something grew, @next/bundle-analyzer tells you what:

// next.config.mjs
import bundleAnalyzer from "@next/bundle-analyzer";

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
});

export default withBundleAnalyzer({
  // your existing config
});

ANALYZE=true next build emits interactive treemaps (client, plus the server and edge bundles). Read the client treemap and mind the units: stat is raw module size, parsed is what the browser must actually parse and execute after minification, gzipped is what crosses the network. Network cost reads from gzip; main-thread cost reads from parsed — a chunk that gzips to 90 KB can be 350 KB parsed, and the phone pays the parsed price. The diagnostic move is always the same: find the unexpectedly large rectangle, check which chunk it sits in (shared vs route), and trace which import dragged it there.

What actually inflates bundles

Three mechanisms account for most real-world bloat. First, barrel filesindex.ts re-exporting an entire package. Importing one icon from a 900-export barrel forces the bundler to resolve and analyze every module behind it, and anything not provably side-effect-free survives tree-shaking into your chunk; icon and component libraries are the classic +300 KB single import. Next ships a targeted fix: optimizePackageImports in next.config, which rewrites barrel imports into direct per-module imports at build time — enabled by default for a list of known-huge libraries (lucide-react, @mui/icons-material, date-fns and friends), and worth adding your internal design-system package to, because your own barrels get no special treatment.

Second, client-component creep. 'use client' marks a boundary, not a component: everything it imports — and everything those import — joins the client graph and ships to the browser. One 'use client' placed high (a layout wrapper, a providers file that also imports utilities) silently converts a server-only subtree into shipped JavaScript. The bundle-shaped symptom: libraries you “only use on the server” appearing in the client treemap, because some client boundary transitively reached them. Third, the heavyweight dependency chosen casually: a charting library on a route where a sparkline would do, a 70 KB date library where Intl would do, lodash imported whole. Audit these in the treemap from largest rectangle down — the top five usually account for more than half the parsed bytes.

Quiz

The build table shows a route's own size as 4 KB, but First Load JS shared by all jumped 120 KB → 410 KB after a release. The analyzer's client treemap shows an icon library filling the largest shared chunk. Where did the bytes most plausibly enter?

The chain from kilobytes to Core Web Vitals

Budgets only get defended when the team believes the causal chain, so make it explicit. Gzipped bytes cost the network; then the phone parses and executes the parsed bytes on the main thread. A mid-range Android executes JavaScript several times slower than the laptop it was reviewed on — an extra 300 KB parsed is roughly an extra second of main-thread work on such a device. That second lands in two places. During load, script evaluation and hydration compete with everything else; long tasks pile up, and any tap during that window waits — which is how JS weight degrades INP, whose “good” threshold is 200 ms at p75. Before that, the bytes themselves compete with the LCP image and critical CSS for bandwidth, and on script-rendered content LCP can’t even paint until execution finishes — the path from chunk size to a blown 2.5 s LCP. CLS completes the triad at 0.1, though its causes live mostly in the previous two lessons. The summary worth pinning to the team wiki: gzip costs the network, parsed costs the main thread, and the main thread is where INP lives.

Why this works

Why budget first-load JS instead of just watching Lighthouse scores? Because a score is an aggregate measured on one synthetic run — noisy, late, and arguable. First-load JS is deterministic per build, diffable per PR, and attributable to a line of code. The score is the lagging indicator your VP sees; the budget is the leading indicator that catches the regression while the offending import is still in the diff under review. Teams that gate on scores litigate flaky CI runs; teams that gate on bytes revert the import.

Budgets in CI: making the number owned

A budget is a number with a build-breaker attached. The mechanics are simple — what matters is which numbers. Gate per-route first-load JS, not total build output: total grows legitimately with every new route, while a route’s first-load growing means real users on that page pay more. A practical starting posture: shared first-load ≤ 130 KB gz, heaviest route ≤ 200 KB gz, and a per-PR delta rule — any route growing by more than ~10 KB needs a justification in the PR description. Tools: size-limit pointed at the built chunks, a script that parses next build’s output JSON and compares against a checked-in budgets.json, or the analyzer artifact uploaded on every PR so the treemap diff is one click away in review.

Two operational details separate budgets that work from budgets that rot. Ratchet, don’t aspire: set the initial budget to current reality plus ~5%, then tighten as you win bytes back — a budget set at the aspirational number on day one is red forever and gets ignored by sprint two. And make the failure actionable: the CI message should name the route, the delta, and the top new modules (the analyzer JSON has them), because “bundle check failed” trains people to bump the budget, while “shared chunk +303 KB: @acme/icons via Header.tsx” trains them to fix the import. Together these two details decide whether a budget is a discipline tool or a bureaucratic checkbox: the ratchet gives it credibility (it can be hit), the actionable message gives it teeth (fixing is easier than bumping). The Hook’s incident, replayed with this gate, dies in review as a red check on a two-line PR — which is the entire point: the budget converts a quarter of degraded field data into a thirty-second code review comment.

Quiz

A team sets a CI bundle budget for total build output size, at the aspirational target of 100 KB while current shared first-load is 240 KB. Six months later bundles are bigger than ever. What went wrong?

Recall before you leave
  1. 01
    What does First Load JS measure, what are the three main bundle inflators, and how does the analyzer expose each?
  2. 02
    Trace the causal chain from bundle kilobytes to failing Core Web Vitals, and describe a CI budget design that actually holds.
Recap

Bundle weight is governed by one number with two parts: First Load JS, the route’s own chunk plus the shared chunks every route downloads — and the shared part is the dangerous one, because growth there degrades every page at once, exactly how a two-line barrel import of an icon package in a root-layout client component becomes a site-wide incident. The build table tells you something grew; the ANALYZE=true treemap from @next/bundle-analyzer tells you what, provided you read the right units — gzip is the network’s price, parsed is the main thread’s, and a 90 KB-gzip chunk can cost 350 KB of parse and execute. The recurring inflators are barrel files (countered by direct imports or optimizePackageImports, which covers popular libraries by default but needs your internal packages added explicitly), use-client creep (a boundary ships its whole transitive import graph, so one high ‘use client’ converts a server subtree into client bytes), and heavyweight dependencies adopted without weighing them. The causal chain to defend in front of your team: gzip bytes compete with the LCP image for bandwidth, parsed bytes become main-thread seconds on mid-range phones, long tasks during hydration make taps wait, and INP slides past its 200 ms threshold while LCP threatens 2.5 s. The defense is a CI budget on per-route first-load JS — ratcheted from current reality plus slack rather than set aspirationally, with per-PR deltas surfaced and failure messages that name the route, the delta, and the offending import — so the regression dies as a red check in review instead of surfacing a month later in p75 field data. Now when you see a shared First Load JS number that feels heavy, open the ANALYZE=true treemap and look for the largest rectangle in the client bundle — the import that dragged it there is almost always fixable in the same PR that added it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.