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.
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.
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
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
- Build for production. Run
npm run build. The dev server never tree-shakes, so avite devmeasurement 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. - Open the treemap. Inspect
dist/stats.html. Ifchart/editorand their vendor dependencies appear despiteApp.tsxonly usingButton, 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. - 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. - Check the
sideEffectsfield. Inspect the offending package’spackage.json(or your own). IfsideEffectsis absent, every module — including the barrel — is treated as impure and skipped by the pruner. Absent is not the same asfalse: absent means “unknown, assume the worst”, whilefalseis 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. - 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 quickgrep -rn "export \*" srcon 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.
// 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.
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
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 setsideEffects: false, because the star hides which names it contributes and the analyzer keeps their union. Root cause: aexport * 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: falseis 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 blanketfalseon a package that contains at least one impure module. Fix by enumerating exactly those files in thesideEffectsarray (["**/*.css", "./src/register.ts"]); confirm by grepping the package for top-level statements that touchwindow,document,customElements, orimport './something.css'.optimizePackageImportsonly 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 ormoduleSideEffectsroute. 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
sideEffectsbut skips some control-flow analysis Rollup performs. If a barrel survives an esbuild minify-only pass, validate against the Rollup-drivenvite buildoutput, which is what ships. The trap is running a quickesbuild --bundlesanity 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.
@/componentsresolving toindex.tslooks 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: atsconfig.jsonpathsentry (and its Viteresolve.aliasmirror) mapping the alias to a directory whoseindex.tsis 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.
Related
- Tree-Shaking Mechanics and Dead Code Elimination — the static-analysis model and full
treeshakeconfiguration reference. - Debugging Tree-Shaking Failures with rollup-plugin-visualizer — read the treemap that exposes a barrel leak.
- Understanding ESM vs CommonJS in Modern Bundlers — why CJS barrels are even harder to prune than ESM ones.
- Code Splitting Strategies for Large Applications — once barrels are fixed, split the surviving code along route boundaries.