Errors and fixes

Bundle grew after a single import: how to find the cause and shrink it

One import can pull a whole package, or move a client boundary and drag server code into the browser. Measure the production build delta first.

Key points

  • A barrel import can pull the whole module: check with the bundle analyzer, not by eye.
  • A new `"use client"` moves everything the file imports into the browser.
  • Always measure the production build: the dev server does not show the final bundle.

Short answer: one import can pull in a whole package, or move a client boundary and bring code into the browser that used to stay on the server. Measure the delta on the production build before you change anything.

Why an import weighs more than the file it names

The bundler does not read your file, it follows a graph. Every import is an edge, and edges are added before tree shaking can remove anything: the cut works only on ESM exports nobody uses, inside packages that declare sideEffects: false. A CommonJS module exposes an object built at runtime, so the export list is not static and the bundler has no way to know what is safe to drop. A one-line import can weigh a hundred modules, and the line never says so.

How to measure it

# Next.js: First Load JS per rotta
next build

# analisi dei moduli
ANALYZE=true next build

# Vite
npx vite-bundle-visualizer

Compare the route before and after the import. A 30% delta on a content page is a signal; a 2% delta may be noise.

Where to look, really

# pesa ogni chunk della build di produzione
npx source-map-explorer ".next/static/chunks/*.js"

# grafo dei moduli per Vite
npx vite-bundle-visualizer

In the browser the equivalent step is Coverage in DevTools: it shows how many of the downloaded bytes actually ran. A low percentage on a new chunk means the import dragged in branches the page never touches. It is worth glancing at the Network panel filtered to JS and sorted by transferred size as well: when the largest file is a locale dataset or a dictionary of icons, the cause is already found.

The four causes

CauseExampleFix
barrel file`import { X } from "lib"` pulls everythingdeep import or an optimisation plugin
CommonJSpackage without ESMESM alternative or dynamic import
new client boundary`"use client"` on a file that imports a lotmove the hook into the leaf
locale data`moment` with every locale`date-fns`, or explicit locales

The deep import

// può includere l'intero set
import { Search, User, Bell } from "some-icon-lib";

// include solo i tre moduli
import Search from "some-icon-lib/icons/search";
import User from "some-icon-lib/icons/user";

With an ESM package and no side effects, tree shaking already does this. With a CommonJS package it cannot: the bundler does not know which parts are safe to drop.

The client boundary

// client-wrapper.tsx
"use client";
import { heavyEditor } from "heavy-editor"; // finisce nel bundle client
export function Editor() {
  return heavyEditor();
}

Every "use client" directive pulls the module and its dependencies into the browser. If the file imports a big library for one function, that library weighs on the page even when the user never reaches it.

When the fix does not work

If the bundle does not shrink after a deep import, the package has side effects or ships CommonJS. Check sideEffects in the library package.json and the module format it publishes. A second case is the bundler cache: delete the build folder before measuring, or you are reading the previous result.

When the usual fix makes things worse

The first fix people apply is to load the library dynamically with dynamic() or React.lazy. The number in the report drops and the cost moves. The chunk does not disappear; it downloads on the first click, so the first interaction waits for a network round trip that it never waited for before. When the component sits above the fold, you have added a loading state and a layout shift to a page that used to paint complete. The ssr: false variant is worse: it removes the component from the build statistics, so the report looks clean while the visitor pays the same price.

The limit of the metric

One limitation worth saying: the byte is not the time. On a fast connection a 40 KB chunk arrives in a few milliseconds, while a small component that hydrates a deep tree keeps the main thread busy and hurts responsiveness. When the goal is the interaction metric, the number to watch is execution time per chunk rather than transferred weight: they are two different measurements and the second does not replace the first.

How to prevent it

  • One bundle budget per route, checked in CI.
  • No barrel imports on public pages.
  • Check the weight after every new "use client".
  • A one-line comment beside every heavy import: whoever adds it explains why, whoever removes it knows what breaks.

Where to continue

In the catalogue: components, dashboard and blocks. The guides: how much a UI library weighs and a hook inside a Server Component; the comparison: copy-paste or headless.

Frequently asked questions

How do I measure the bundle in Next.js?

next build prints First Load JS per route. For the per-module detail you need @next/bundle-analyzer.

Why do icons weigh so much?

A barrel import can pull the whole set. Deep imports, or an optimisation plugin, include only the icons you use.

Does "use client" always grow the bundle?

No, but it moves the module and its dependencies to the client. If the file imports a big library, that library lands in the browser bundle.

Should tree shaking not fix this?

It works on ESM modules with no side effects. A CommonJS package, or one that declares side effects, does not get trimmed.

How much is too much?

There is no universal number. Compare the route with the others: a 30% jump after one import deserves a look.