Why Barrel Files Break Tree-Shaking in Webpack
How barrel file re-exports prevent Webpack static analysis, defeat usedExports optimisation, and how to replace them with explicit named exports for tree-shaking.
You add a single import from your component library — import { Button } from '@acme/ui' — and your production bundle grows by 40 kB. The culprit is rarely the Button component itself. It is the index.ts barrel file that re-exports every symbol in the package with export * from './button', export * from './modal', export * from './table', and so on. Webpack ingests the entire namespace, cannot statically determine which re-exports you actually use, and keeps everything. This page explains the exact mechanism, the minimal reproduction, and the step-by-step fix.
Root Cause: Namespace Objects Block Static Analysis
Webpack’s dependency graph is built by a static AST pass using its embedded Acorn parser. For a named import like import { Button } from './button', the parser can resolve Button to a specific binding in a specific module and mark every other export as unused. Tree-shaking then discards those unused exports during the usedExports optimisation pass.
The export * from './module' syntax changes this completely. Webpack must generate a namespace object — an object whose keys are all the re-exported names — to maintain CommonJS/ESM interoperability. That namespace object is evaluated lazily via __webpack_require__.d() getters, and Webpack cannot statically prove which keys of that object will be accessed downstream. So it conservatively retains every module the barrel touches.
This interacts badly with eliminating barrel file anti-patterns across a library: even one wildcard re-export in a transitive dependency can pull in every module in that subtree.
The relevant Webpack stats flag that confirms this is happening:
"optimizationBailout": ["Barrel file re-export causes namespace evaluation"]
Run webpack --mode production --json > stats.json and search for optimizationBailout to find every module boundary where pruning has stopped.
Minimal Reproduction
The smallest setup that triggers the problem is three files and a Webpack config:
src/
button.ts // exports Button
modal.ts // exports Modal
table.ts // exports Table
index.ts // barrel: export * from each
app.ts // import { Button } from './src/index'
webpack.config.ts
src/index.ts (the barrel):
export * from './button';
export * from './modal';
export * from './table';
app.ts (the consumer):
import { Button } from './src/index';
console.log(Button);
webpack.config.ts:
import type { Configuration } from 'webpack';
const config: Configuration = {
mode: 'production',
entry: './app.ts',
optimization: {
usedExports: true,
providedExports: true,
},
};
export default config;
Expected: bundle contains only Button. Actual: bundle contains Modal and Table too because the export * namespace prevents Webpack from knowing which re-exports are consumed.
Step-by-Step Fix
Step 1: Audit wildcard re-exports
grep -rn "export \*" src/
Expected output — each line is a barrel export to replace:
src/index.ts:1:export * from './button';
src/index.ts:2:export * from './modal';
src/index.ts:3:export * from './table';
Step 2: Replace wildcard re-exports with explicit named exports
Before (src/index.ts):
export * from './button';
export * from './modal';
export * from './table';
After (src/index.ts):
export { Button, ButtonGroup } from './button';
export { Modal, ModalHeader, ModalBody } from './modal';
export { Table, TableRow, TableCell } from './table';
Named re-exports are statically analysable. Webpack can now see that Modal and Table are not referenced in app.ts and marks them unused.
For large libraries with dozens of barrel files, the codemod in the eliminating barrel file anti-patterns guide automates this replacement across the whole repository.
Step 3: Declare side-effect-free modules
In package.json, tell Webpack that importing from this package will not trigger global mutations:
Before:
{
"name": "@acme/ui",
"version": "1.0.0"
}
After:
{
"name": "@acme/ui",
"version": "1.0.0",
"sideEffects": false
}
HAZARD PREVENTION: Do not set
"sideEffects": falseglobally if any module executes side-effectful top-level code (CSS imports, polyfills, global store initialisation). Use a glob array instead:"sideEffects": ["./src/**/*.css", "./src/polyfills/*.ts"]. IncorrectsideEffects: falseon a module that does have side effects causes Webpack to silently drop required initialisation code. See configuring sideEffects for CSS and asset imports for the full pattern.
Step 4: Enable all four Webpack optimisation flags together
import type { Configuration } from 'webpack';
const config: Configuration = {
mode: 'production',
entry: './app.ts',
optimization: {
providedExports: true, // track which exports each module provides
usedExports: true, // track which of those are actually consumed
concatenateModules: true, // scope-hoist: merge modules into one scope
sideEffects: true, // honour the sideEffects field in package.json
},
};
export default config;
All four flags must be active together. usedExports alone marks code as unused but concatenateModules (scope hoisting via ModuleConcatenationPlugin) is what actually removes it by inlining modules into a single scope where dead assignments are eliminated.
Step 5: (Optional) Bypass the barrel with resolve.alias
If you are consuming a third-party package that ships barrel files you cannot modify, point resolve.alias directly at the ESM source:
import type { Configuration } from 'webpack';
import path from 'path';
const config: Configuration = {
resolve: {
alias: {
'@acme/ui': path.resolve(__dirname, 'node_modules/@acme/ui/dist/esm/src'),
},
},
};
export default config;
This makes every import { X } from '@acme/ui' resolve to the granular source files directly, bypassing the barrel entirely.
Verification Command
Run a production build, emit stats, and inspect with the bundle analyser:
webpack --mode production --json > stats.json && \
npx webpack-bundle-analyzer stats.json
In the analyser UI, verify that modal.js and table.js no longer appear in the tree. Then search the stats file for remaining bailouts:
node -e "
const s = require('./stats.json');
s.modules
.filter(m => m.optimizationBailout?.length)
.forEach(m => console.log(m.name, m.optimizationBailout));
"
Expected output after fix: empty — no modules listed. Each listed module is still a barrel boundary that blocked pruning.
Also confirm ModuleConcatenationPlugin is working — scope-hoisted modules appear as (concatenated) in the stats:
node -e "
const s = require('./stats.json');
const concat = s.modules.filter(m => m.name?.includes('concatenated'));
console.log('Scope-hoisted modules:', concat.length);
"
Edge Cases / Gotchas
export * as ns from './module'creates a named namespace re-export. This is statically analysable (Webpack knows the namespace object is bound tons) but the entire./moduleis still retained because all its exports must be available on the namespace. Prefer explicit named bindings.- pnpm symlinks and hoisting: pnpm’s strict mode prevents phantom dependencies but does not change how Webpack resolves
export *. The barrel-file problem is identical across npm, Yarn, and pnpm. - TypeScript
export type * from: Type-only wildcard re-exports are erased before JavaScript is emitted. They cannot cause bundle bloat — only value re-exports matter. ModuleConcatenationPluginbailout on barrel files: Even withconcatenateModules: true, Webpack logsModule has side effects (or module concatenation is not possible)for barrel files. This is separate from thesideEffectsfield — it means scope hoisting was skipped for that module chain entirely.- Vite (development mode): Vite’s dev server uses native ESM and requests each module individually. Barrel files there cause cascading HTTP waterfalls (dozens of requests per page load) rather than bundle bloat. In production, Vite uses Rollup, which handles named re-exports better than wildcard ones for the same reasons.
@typescript-eslint/no-barrel-filesrule: Adding this ESLint rule catches new barrel files before they enter the codebase. Pair it with the codemod migration to prevent regression.sideEffectsin Webpack config vspackage.json: ThesideEffectsflag inoptimizationtells Webpack to read thesideEffectsfield frompackage.json. If the package does not have that field, Webpack falls back to treating all modules as having side effects.
FAQ
Why does tree-shaking still fail after I set sideEffects: false?
sideEffects: false is necessary but not sufficient. Webpack must also have usedExports: true and providedExports: true in optimization. Without those flags, Webpack does not track which exports are consumed and cannot prune unused code even when modules are declared side-effect-free. Confirm all four optimization flags are active together (see Step 4 above).
Do named re-exports (export { X } from './x') also block tree-shaking?
No — named re-exports are statically analysable. Webpack can trace the binding from the import site back to the original declaration and mark everything else as unused. The problem is specifically export *, which forces a namespace object with keys that cannot be statically enumerated. Replace wildcard re-exports with explicit named re-exports and tree-shaking resumes.
Will this fix work in Webpack 4 as well as Webpack 5?
The sideEffects flag and usedExports optimisation both exist in Webpack 4, but Webpack 5 added inner-graph analysis and improved scope hoisting via ModuleConcatenationPlugin that makes the gains from removing barrel files significantly larger. Upgrading to Webpack 5 is strongly recommended to get the full benefit, especially for libraries that use the exports field in package.json.
My barrel file only re-exports types — does it still cause bloat?
TypeScript export type { Foo } from './foo' declarations are erased at compile time and do not appear in emitted JavaScript. They cannot cause runtime bloat. The problem is value re-exports mixed with or mistaken for type re-exports — always check what the compiled .js output actually contains, not the .ts source.
Does Vite have the same barrel-file problem?
In development mode, Vite’s native ESM server requests each module individually, so barrel files cause many cascading HTTP requests rather than bundle bloat. In production, Vite uses Rollup for bundling, and Rollup’s tree-shaking is generally more aggressive than Webpack’s — but wildcard re-exports still create namespace objects that limit pruning in both bundlers. The fix is the same: replace export * with explicit named re-exports.
Related
- Eliminating Barrel File Anti-Patterns — migration strategy and codemod for replacing barrel files across a whole library codebase.
- Implementing the sideEffects Flag Correctly — how to declare side-effect-free modules without breaking CSS and polyfill imports.
- Measuring Bundle Impact with Bundlephobia and Webpack Analyzer — tooling to quantify how much dead code barrel files are adding before and after the fix.