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.

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.

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

  1. Build for production. Run npm run build. The dev server never tree-shakes, so a vite dev measurement is meaningless here.
  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.
  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.
  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.
  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.

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: 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. Enumerate those files instead of blanket-disabling.
  • optimizePackageImports only 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 or moduleSideEffects route.
  • 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.
  • TypeScript path aliases hide the barrel. @/components resolving to index.ts looks like a direct import in source. Always check the resolved path, not the alias, when tracing in the visualizer.