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.

How one barrel import pulls in the folder Flow from importing one component through a barrel file to the bundler having to evaluate every re-exported module. How one barrel import pulls in the folder import { Button } from '@/components' Barrel index.ts export * from 30 files Side effects? unknown or yes Kept DataGrid, Chart, Editor Shipped +180KB on every page

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 sideEffects in package.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.

Bundle cost of importing one Button Bar chart of bytes shipped when importing a single Button component via a barrel versus a direct path, under different side-effect settings. Bundle cost of importing one Button Barrel, sideEffects unset 186KB Barrel, one module with CSS import 64KB Barrel, sideEffects declared 9KB Direct path import 8KB

Step-by-Step Resolution

1. Declare side effects for internal packages

json
{
  "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

typescript
// 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.

javascript
// 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.

Fixing a leaky barrel Decision sequence for fixing a barrel file that pulls unused modules into the bundle. Fixing a leaky barrel Package has no sideEffects field? Add sideEffects listing only CSS and true effects yes no A re-exported module has top-level effects? Move the effect into an explicit function yes no Re-exports CommonJS modules? Convert to ESM or import directly yes no Barrel is fine — keep it for ergonomics

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: false without 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.