Why Barrel Files Break Tree Shaking — and How to Fix It
This guide belongs to Tree Shaking and Dead Code Elimination within JavaScript Bundle Optimization & Code Splitting. A barrel file is an index.ts that re-exports everything from a folder: export * from './Button'; export * from './Modal'; export * from './DataGrid';. It makes imports tidy — import { Button } from '@/components' — and it is one of the most common reasons a page that uses one button ships the data grid too.
In theory, bundlers follow re-exports and drop unused modules. In practice, they can only drop a module if they can prove that evaluating it has no side effects. A single module in the barrel with top-level code — a CSS import, a polyfill, a registry call, a class with static initialisers — forces the bundler to keep it, and anything it imports. Barrels also slow builds and development servers, because every import of the barrel makes the tool resolve and parse every file behind it.
Rapid Diagnosis
- Import one small export through the barrel in a test entry and check the bundle: if unrelated components appear, the barrel is leaking.
- Check
sideEffectsinpackage.json. If it is missing for your internal packages, bundlers assume every file has side effects. - Search re-exported modules for top-level effects:
import './styles.css',register(...),window.x =, or immediately invoked functions. - Measure build and dev-server time. In large codebases, barrel imports noticeably slow module resolution; Next.js and Vite both document this.
Root Cause Analysis
1. Unknown side effects. Without "sideEffects": false or a precise list, bundlers must keep every module reached through the barrel, in case importing it does something.
2. Real side effects in one module. Even with sideEffects declared, a module that genuinely has top-level effects is kept — and if it imports heavy dependencies, so are they.
3. export * from CommonJS. Re-exporting a CommonJS module makes its exports dynamic; bundlers cannot shake it.
4. Namespace re-exports. export * as utils from './utils' creates a namespace object; some bundlers keep the whole namespace if it is passed around.
Step-by-Step Resolution
1. Declare side effects for internal packages
{
"name": "@acme/components",
"sideEffects": ["**/*.css", "./src/polyfills.ts"]
}
Everything not listed is treated as pure, so unused re-exports are dropped. The trade-off is correctness: if a module you did not list does rely on a side effect, it will disappear — the failure mode described in sideEffects: false broke my CSS imports.
Expected outcome: importing one component through the barrel ships roughly what a direct import ships.
2. Remove top-level effects from re-exported modules
// Before: importing the module registers the component globally.
import { registry } from '../registry';
export class DataGrid { /* ... */ }
registry.register('data-grid', DataGrid);
// After: export only; registration is an explicit call in app setup.
export class DataGrid { /* ... */ }
export const registerDataGrid = (r: Registry) => r.register('data-grid', DataGrid);
// trade-off: callers must now register explicitly. That is more code at the
// call site, but it is also clearer about what the app depends on.
Expected outcome: modules behind the barrel become genuinely removable.
3. Use direct imports for heavy modules, or let the framework optimise barrels
For heavy components, import from their own paths. Next.js offers optimizePackageImports, which rewrites barrel imports from listed packages into direct imports at build time.
// next.config.js
module.exports = { experimental: { optimizePackageImports: ['@acme/components', 'lucide-react'] } };
// trade-off: the rewrite relies on the package's export structure; it works
// for well-behaved packages and may need disabling for ones with unusual exports.
Expected outcome: imports stay ergonomic while the bundle contains only what is used.
4. Enforce with lint rules
Prevent new barrels from reintroducing the problem in hot paths with ESLint rules such as no-restricted-imports for known heavy barrels, or plugins that flag barrel files entirely.
Verification
Build a test entry importing a single small export from the barrel and confirm its bundle size matches a direct import. Re-run the route's bundle analysis: heavy components should appear only in chunks for routes that use them. Measure build time too — removing barrels from hot paths often speeds up development server startup and hot reload noticeably.
Worked Example: An Icon Barrel
A design system exported 1,400 icons through icons/index.ts, each as a React component. Pages imported { SearchIcon, CloseIcon } from it. The package had no sideEffects field, and the icon components were generated with a wrapper that called createIcon() at module scope — which bundlers could not prove pure. Every page shipped all 1,400 icons: 310KB uncompressed. Adding "sideEffects": false and marking the factory calls with /*#__PURE__*/ (the subject of using PURE annotations for side-effect-free calls) reduced it to the dozen icons actually used, about 4KB.
Common Mistakes
- Declaring
sideEffects: falsewithout auditing. Global CSS imports and polyfills silently vanish. - Nested barrels. A barrel that re-exports other barrels multiplies the surface area for side effects; flatten them.
- Assuming dev-mode bundles reflect production. Dev servers do not tree-shake; check production builds.
- Using barrels for server-only code in shared folders. A barrel mixing client and server utilities can pull server-only modules into client bundles, or break the build entirely.
Edge Cases: When Barrels Bite Even With sideEffects
Re-exported default exports. export { default as Button } from './Button' is fine; export default from patterns and mixed CommonJS interop can produce wrapper objects that bundlers keep.
Barrels that compute exports. An index that builds its exports dynamically — iterating a folder with import.meta.glob, or assembling an object of components — defeats static analysis entirely. Generated barrels should emit plain export … from lines.
Type-only re-exports. In TypeScript, re-exporting types alongside values is harmless after compilation, but isolatedModules setups may require export type to avoid emitting runtime imports for types. A stray runtime import of a types-only module can pull an otherwise unused file into the graph.
Server and client boundaries. In frameworks with server components, a barrel that re-exports both server-only and client components can force the client bundle to include server code paths or trigger build errors. Split barrels by environment.
FAQ
Should we ban barrel files entirely?
Not necessarily. Barrels of pure, small modules with sideEffects declared tree-shake well and keep imports tidy. Problems arise with barrels over heavy or effectful modules, CommonJS re-exports, and very large barrels that slow tooling. Restrict barrels in those cases and keep them elsewhere.
Does Vite's dev server suffer from barrels?
Yes. Vite serves modules individually in development, so importing a barrel makes the browser request every module behind it. Large barrels can mean hundreds of requests on page load in dev, which is why Vite's documentation recommends avoiding them for large libraries.
Do TypeScript path aliases change anything?
No. Aliases like @/components are resolved to paths before bundling; whether tree shaking works depends on what the resolved module contains, not how it was referenced.
How do I find which barrel is responsible?
In a bundle analyser's network or import-chain view, select an unexpected module and follow its importers upward. The first index.ts in the chain that your code imports is the barrel to fix.
Related
- Writing tree-shakeable library exports — designing exports that shake from the start.
- Fixing tree-shaking issues with lodash and moment — third-party libraries with the same problem.
- Building tree-shakeable libraries with Vite library mode — packaging internal libraries correctly.