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.
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.
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. - 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. - 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. - 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. - 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.
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.
Verification
Rebuild and compare against the baseline you captured before the fix.
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.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. Enumerate those files instead of blanket-disabling.optimizePackageImportsonly covers listed packages. It is an allowlist, not automatic. Add each barrel-heavy dependency explicitly, and remember it is a Next.js feature — plain Vite needs the deep-import ormoduleSideEffectsroute.- 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. - TypeScript path aliases hide the barrel.
@/componentsresolving toindex.tslooks like a direct import in source. Always check the resolved path, not the alias, when tracing in the visualizer.
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.