Eliminating Barrel File Side Effects in Tree-Shaking

A single import { Button } from '@/components' can drag your entire component library into the bundle when that path resolves to a barrel index.ts that re-exports everything. This page is the focused fix for that failure; for the underlying static-analysis model it builds on, see Tree-Shaking Mechanics and Dead Code Elimination before changing import patterns wholesale. The symptom is a bundle far larger than the symbols you actually reference, and the cause is almost always a re-export hub the analyzer cannot prove pure.

This problem exists because tree-shaking is a static, conservative process. The bundler does not run your code; it reads the module graph and decides which statements are safe to delete. Deletion is only safe when the analyzer can prove that keeping a statement out changes nothing observable — no network call skipped, no global left unpatched, no CSS left uninjected. A barrel sits directly on that proof boundary. It is a module whose entire body is export … from './sibling' lines, and to satisfy the single named import you asked for, the bundler has to reason about the whole file. If any sibling in that file might do something on evaluation, the conservative default is to keep it, because deleting a real side effect is a correctness bug and shipping dead bytes is merely a performance bug.

Where this bites in the pipeline is the production Rollup pass, not the dev server. In development Vite serves unbundled ESM over native import, so a barrel costs you extra module requests but never inflates a shipped artifact — the leak is invisible until vite build runs Rollup’s tree-shaker and the treemap shows a chart library you never rendered sitting inside your main chunk. Getting this wrong scales badly: one barrel imported from fifty route files does not cost you fifty small leaks, it costs you the entire re-exported surface once, permanently resident in whatever chunk the barrel lands in. The fixes below are ordered by how much of the graph they can rescue and by who owns the offending file.

How a barrel index re-export pulls unused siblings into the bundle Importing one symbol through a barrel forces evaluation of every re-exported sibling unless the package is declared side-effect free or imported deeply. import { Button } from '@/components' App entry needs Button components/index.ts export * from './*' Button (used) Chart (unused) Editor (unused) Modal (unused) Default behavior: barrel has unknown side effects, so all siblings evaluate and survive Chart, Editor, Modal and their transitive deps land in the bundle Fixes that restore pruning sideEffects: false • deep import '@/components/button' • optimizePackageImports Rollup treeshake.moduleSideEffects: 'no-external'
Figure: one symbol imported through a barrel keeps every re-exported sibling until the barrel is proven side-effect free or bypassed with a deep import.

Problem scope

A barrel file is an index.ts whose only job is to re-export the contents of a directory so consumers can write import { X } from './feature' instead of import { X } from './feature/x'. It is ergonomic and it is a tree-shaking hazard. When you import a single symbol through a barrel, the bundler must evaluate the barrel module, and the barrel references every sibling. Unless the analyzer can prove each of those siblings is free of side effects, it keeps them — along with their transitive dependencies. The result is a bundle dominated by code no route ever calls.

The mechanics are worth stating precisely because they explain why the “obvious” fixes do not all work. Under ESM semantics, importing a name from a barrel establishes a live binding to the module the barrel re-exports from, and the specification requires every module in the dependency chain to be instantiated and evaluated before the importing module runs. Tree-shaking is an optimization layered on top of that guarantee: the bundler is allowed to skip evaluating a module only if it can prove doing so is unobservable. So the barrel itself is cheap — a handful of re-export records — but each re-export record names a sibling, and the sibling is what carries weight. The pruner walks from your used Button binding outward; the question it must answer for Chart and Editor is not “did anyone import them” but “is it safe to not evaluate them”. Absent a purity signal, the answer is no, and their transitive imports (chart.js, a rich-text engine) are pulled in behind them. The bundle size you observe is therefore a function of the heaviest unused sibling, not of the barrel’s own line count.

A second-order effect makes barrels worse than a naive re-export count suggests: chunking. Because every route that touches the barrel shares the same evaluated module, Rollup’s chunk-assignment heuristics tend to hoist the whole re-exported surface into a common chunk shared by all of them. What could have been dead code that never loaded becomes eagerly-loaded shared code on the critical path. That is why a barrel leak often shows up first as a regression in initial-load metrics rather than in total build size.

One import through a barrel evaluates every sibling Importing one symbol through a barrel forces the bundler to evaluate the barrel, which references every sibling; unless each sibling is provably pure, all of them and their transitive dependencies land in the bundle. import { Button } barrel index.ts Button (used) Chart, Editor, Modal (unused, kept)
Figure: the barrel is an evaluation hub — one import forces every re-exported sibling to be considered live.

How the purity proof actually works

Rollup decides a module is prunable through a combination of the sideEffects field and its own control-flow analysis, and understanding the interaction is what lets you predict whether a given fix will land. The sideEffects field in package.json is a coarse, declarative signal: false asserts that no module in the package does anything on evaluation beyond defining exports, so the bundler may drop any module whose exports go unused. An array narrows that assertion to an allowlist of files that do run on import — typically stylesheet imports and registration modules. This field is a promise the bundler trusts without verifying; it is the cheapest possible proof because you supplied it.

When the field is absent, Rollup falls back to inference. It parses each module and asks, statement by statement, whether the statement could have an effect visible outside the module: a bare function call at top level, an assignment to a member of an imported or global object, a new expression whose constructor is not provably pure. A module of nothing but export { X } from './x' re-export records has no such statements, so Rollup can in principle prove the barrel itself pure — but it must still recurse into each ./x, and that is where inference usually stalls on real components that touch window, register a web component, or import a CSS file. The practical consequence: for your own first-party barrels, the deep import and an accurate sideEffects field both work; for third-party packages, inference is unreliable enough that you should force the outcome rather than hope for it.

esbuild reaches a similar decision through a leaner path. It honors sideEffects but performs less speculative control-flow analysis than Rollup, so it is more likely to keep a sibling that Rollup would have proven droppable. This matters when you use esbuild for a fast type-strip-and-minify step and assume the shipped Vite build behaves identically — it does not, and the Vite build (Rollup) is the artifact whose size you should trust.

Prerequisites & reproducible setup

Pin a toolchain and build a minimal reproduction so you can measure the delta each fix produces.

  • Vite 5.x or 6.x (Rollup 4.x for the production build), Node 20+.
  • rollup-plugin-visualizer for the treemap that proves which siblings survived.
npm create vite@latest barrel-repro -- --template react-ts
cd barrel-repro
npm i
npm i -D rollup-plugin-visualizer
The repro pairs a small used export with heavy unused ones The barrel re-exports a small Button that the app uses plus a Chart and Editor that each pull a heavy library and are never rendered, so the treemap can show which siblings survived. Button (used)small Chart (unused)pulls a charting lib Editor (unused)pulls a rich-text lib A deliberately heavy sibling makes the leak measurable
Figure: the repro's heavy unused siblings are what make a successful fix show up as a byte delta.

Create a barrel that re-exports several components, where one is deliberately heavy:

// src/components/index.ts — the barrel under test
export { Button } from './button';   // small, the only thing we use
export { Chart } from './chart';     // pulls a charting lib, never rendered
export { Editor } from './editor';   // pulls a rich-text lib, never rendered
// src/App.tsx — imports ONE symbol through the barrel
import { Button } from './components';
export default function App() {
  return <Button>Save</Button>;
}

Wire up the visualizer so every build emits both a human treemap and a machine-readable graph:

// vite.config.ts — Vite 5.x / 6.x, Rollup 4.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({ filename: 'dist/stats.html', template: 'treemap', gzipSize: true, emitFile: true }),
    visualizer({ filename: 'dist/stats.json', template: 'json', emitFile: true })
  ]
});

Diagnosis workflow

Five-step barrel-leak diagnosis Build for production, open the treemap and look for ghost siblings, confirm the import chain routes through the barrel, check the sideEffects field, and grep for export star wildcards which are the worst offenders. 1 prod buildnot dev 2 treemapghost siblings? 3 import chainvia barrel? 4 sideEffectsdeclared? 5 grep export *worst case
Figure: the treemap surfaces the leak; steps 4–5 identify whether it is a missing flag or a wildcard re-export.
  1. Build for production. Run npm run build. The dev server never tree-shakes, so a vite dev measurement is meaningless here. Dev mode serves each module as its own native-ESM request precisely so that edits stay fast and granular; tree-shaking is deferred entirely to the Rollup build. If you measure the leak against the dev server you will either see nothing (no bundling happened) or you will see every module loaded and conclude, wrongly, that the leak is unfixable. Always reproduce against the same command CI ships.
  2. Open the treemap. Inspect dist/stats.html. If chart/editor and their vendor dependencies appear despite App.tsx only using Button, the barrel is the leak. This is the classic “ghost module” signature surfaced in Debugging Tree-Shaking Failures with rollup-plugin-visualizer. The tell is a large rectangle whose name you never typed anywhere in your source — a vendor library that only an unused sibling depends on. Toggle the gzip-size view; a sibling that looks small raw can be large after its transitive deps are counted, and the gzip figure is the one that predicts transfer cost.
  3. Confirm the import chain. Use the visualizer sidebar to trace the heavy node back through components/index.ts. A path that runs through the barrel rather than directly from your component confirms the diagnosis. This step matters because a heavy sibling can also arrive through a legitimate direct import elsewhere in the app, in which case the barrel is innocent and the fix is different. Only when the sole path to the heavy node passes through the barrel do the fixes below apply. If two paths exist, resolve the direct one first, then re-measure.
  4. Check the sideEffects field. Inspect the offending package’s package.json (or your own). If sideEffects is absent, every module — including the barrel — is treated as impure and skipped by the pruner. Absent is not the same as false: absent means “unknown, assume the worst”, while false is an explicit promise the bundler will act on. Many packages ship without the field simply because their author never considered downstream tree-shaking, which is why third-party barrels leak far more often than first-party ones.
  5. Grep for export *. Wildcard re-exports (export * from './x') are worse than named re-exports: they force the bundler to consider every export of every sibling as potentially live. A quick grep -rn "export \*" src on the offending directory usually finds the culprit in seconds. Wildcards also defeat renaming and dead-export detection because the analyzer cannot know at parse time which names the star actually contributes, so it must keep the union of all of them.

The fix

Three complementary changes restore pruning. The first is mandatory; the second and third make the result robust against dependencies you do not control.

Three complementary barrel fixes by ownership Declare sideEffects false in your own package.json, use a deep import in application code you own, and for third-party barrels you cannot edit use optimizePackageImports in Next or a Rollup moduleSideEffects no-external override. your librarysideEffects: falsesafe for consumers your app codedeep importno hub to evaluate third-party barreloptimizePackageImportsor moduleSideEffects Which fix depends on who owns the barrel
Figure: pick the fix by ownership — your library, your app, or a dependency you cannot edit.
// package.json — declare the project (or library) free of import-time side effects.
// Enumerate ONLY the files that must execute on import (CSS, polyfills).
{
  "name": "@acme/ui",
  "type": "module",
  "sideEffects": ["**/*.css", "./src/register-icons.ts"]
}
// src/App.tsx — bypass the barrel with a deep import to the exact module.
// This is the single most reliable fix: no re-export hub to evaluate.
import { Button } from './components/button';

export default function App() {
  return <Button>Save</Button>;
}
// next.config.ts — Next 15: let the framework rewrite barrel imports to deep imports
// at build time, so you keep the ergonomic import syntax without the cost.
import type { NextConfig } from 'next';

const config: NextConfig = {
  experimental: {
    optimizePackageImports: ['@acme/ui', 'lucide-react', '@mui/material']
  }
};
export default config;
// rollup.config.js (or vite.config.ts build.rollupOptions.treeshake) — Rollup 4.x.
// Force external packages to be droppable even when their package.json
// fails to declare sideEffects, which is the common third-party case.
export default {
  treeshake: {
    moduleSideEffects: 'no-external'
  }
};

Use the deep import in application code you own. Use optimizePackageImports (Next) or the moduleSideEffects override (raw Rollup/Vite) when the barrel lives inside a third-party package you cannot edit. sideEffects: false is what makes your own published library safe for downstream consumers to tree-shake — without it, every consumer inherits this same problem.

The trade-offs between these are concrete, not stylistic. The deep import is the most reliable because it removes the hub from the graph entirely — there is no barrel to evaluate, so no purity question to answer — but it costs ergonomics: import statements grow longer, and a directory reshuffle now touches every call site instead of one barrel. sideEffects: false keeps the ergonomic barrel import intact and is the only fix that helps consumers of a package you publish, but it is a correctness assertion you must keep true forever; the day someone adds a CSS import or a global registration to a module the field claims is pure, the bundler will silently drop it and produce a runtime bug that no type checker catches. optimizePackageImports is the best of both for the packages it covers — you write import { Button } from '@acme/ui' and Next rewrites it to the deep path at build time — but it is an explicit allowlist, so a barrel-heavy dependency you forget to list keeps leaking with no warning.

For a library you own, the durable answer is to make the barrel itself analyzable rather than choosing between the fixes above. Keep re-exports named, never wildcard, and split anything with a genuine import-time effect into a separately-imported entry point so the main barrel can honestly claim purity:

// src/index.ts — a barrel that stays prunable: named re-exports only,
// and NOT a single top-level statement with a side effect. Rollup 4.x.
export { Button } from './button';
export { Chart } from './chart';
export { Editor } from './editor';
// side-effecting setup lives behind its own entry point, imported explicitly:
//   import '@acme/ui/register-icons';
// so it never rides along with a `import { Button }` and the barrel above
// can be honestly declared pure via package.json "sideEffects".

Verification

Rebuild and compare against the baseline you captured before the fix.

Three barrel-fix verification checks Total dist size should drop, source-map-explorer should confirm the unused siblings are gone, and a CI check asserts the forbidden dependency names are absent from the visualizer JSON. du -sh dist size drops source-map-explorer siblings gone check-barrel.js (forbidden set) no leak → exit 0
Figure: size, explorer, and a CI script — the last stops an export * from silently regressing.
npm run build
du -sh dist                          # total output should drop
npx source-map-explorer 'dist/assets/*.js'   # confirm chart/editor are gone

The treemap should no longer contain the unused siblings. For a numeric gate, assert the forbidden modules are absent from the visualizer JSON:

// scripts/check-barrel.js — Node 20+, run after npm run build
import { readFileSync } from 'node:fs';

const stats = JSON.parse(readFileSync('dist/stats.json', 'utf-8'));
const FORBIDDEN = ['chart.js', 'react-quill']; // deps that only Chart/Editor pull
const seen = new Set();
(function walk(n) { if (n.name) seen.add(n.name); n.children?.forEach(walk); })(stats);

const leaked = FORBIDDEN.filter((m) => [...seen].some((n) => n.includes(m)));
if (leaked.length) {
  console.error(`Barrel leak: ${leaked.join(', ')} still bundled`);
  process.exit(1);
}
console.log('Barrel side effects eliminated: no forbidden modules in bundle');

Wire node scripts/check-barrel.js into CI after the build step so a future export * cannot silently reintroduce the regression.

Gotchas & edge cases

Four barrel-elimination edge cases export star re-exports are the worst; sideEffects false is a lie if a module mutates globals on import; optimizePackageImports is an allowlist not automatic; and TypeScript path aliases hide the barrel so check the resolved path. export * is the worst wildcards force the whole sibling surface live — prefer named re-exports. sideEffects: false can lie registering an element or injecting CSS is real — enumerate those files. optimizePackageImports is an allowlist add each barrel-heavy dep explicitly; it's a Next feature, not plain Vite. aliases hide the barrel @/components looks direct in source — check the resolved path in the treemap.
Figure: two "your fix isn't complete" traps and two "the fix doesn't apply where you think" traps.
  • export * re-exports are the worst offenders. Prefer explicit named re-exports (export { Button } from './button') — wildcards force the analyzer to treat the whole sibling surface as reachable. The symptom is a barrel that leaks even after you set sideEffects: false, because the star hides which names it contributes and the analyzer keeps their union. Root cause: a export * from './x' line whose target module has an effect, or simply too many exports to prune confidently. Fix by rewriting to named re-exports; confirm by rebuilding and checking the treemap no longer lists the sibling’s vendor deps.
  • sideEffects: false is a lie if a module mutates globals on import. Registering a custom element, patching a prototype, or injecting CSS at module scope is a real side effect. The symptom here is nastier than a size regression — it is a runtime bug, because the bundler believed your promise and dropped a module that was supposed to run. Root cause: a blanket false on a package that contains at least one impure module. Fix by enumerating exactly those files in the sideEffects array (["**/*.css", "./src/register.ts"]); confirm by grepping the package for top-level statements that touch window, document, customElements, or import './something.css'.
  • optimizePackageImports only covers listed packages. It is an allowlist, not automatic. The symptom is one dependency still ballooning the bundle while its neighbors are lean — the lean ones are on the list and the fat one is not. Add each barrel-heavy dependency explicitly, and remember it is a Next.js feature — plain Vite needs the deep-import or moduleSideEffects route. Confirm by diffing the treemap before and after adding the package name; a covered package’s siblings vanish from the chunk.
  • esbuild prunes barrels less aggressively than Rollup. esbuild honors sideEffects but skips some control-flow analysis Rollup performs. If a barrel survives an esbuild minify-only pass, validate against the Rollup-driven vite build output, which is what ships. The trap is running a quick esbuild --bundle sanity check, seeing the leak, and “fixing” a bundle that was never going to ship that way — or the reverse, seeing esbuild keep a sibling and assuming production will too. Confirm the real behavior only against the production Rollup build.
  • TypeScript path aliases hide the barrel. @/components resolving to index.ts looks like a direct import in source. The symptom is that grep for the barrel path finds nothing because every call site uses the alias, and the import looks like a specific-module import to a human reviewer. Root cause: a tsconfig.json paths entry (and its Vite resolve.alias mirror) mapping the alias to a directory whose index.ts is the barrel. Always check the resolved path, not the alias, when tracing in the visualizer, and prefer aliases that point at leaf modules over directories.