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.

Barrel file namespace inflation in Webpack Left side: a consumer imports Button only. Centre: barrel index.ts re-exports wildcard from button, modal, and table modules. Right side: Webpack's output bundle contains all three modules because the namespace object prevents static pruning. app.ts import { Button } index.ts (barrel) export * from './button' export * from './modal' export * from './table' namespace object { Button, Modal, Table … } Webpack cannot prune unknown key access bundle.js button.js ✓ (used) modal.js ✗ (unused) table.js ✗ (unused) +40 kB dead code retained by namespace
A single wildcard barrel causes Webpack to retain every module it touches, even when only one export is consumed.

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": false globally 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"]. Incorrect sideEffects: false on 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 to ns) but the entire ./module is 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.
  • ModuleConcatenationPlugin bailout on barrel files: Even with concatenateModules: true, Webpack logs Module has side effects (or module concatenation is not possible) for barrel files. This is separate from the sideEffects field — 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-files rule: Adding this ESLint rule catches new barrel files before they enter the codebase. Pair it with the codemod migration to prevent regression.
  • sideEffects in Webpack config vs package.json: The sideEffects flag in optimization tells Webpack to read the sideEffects field from package.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

Up: Eliminating Barrel File Anti-Patterns