Skip to content

Tree-Shakable Library Exports

advanced18 min read

The Bundle Tax Nobody Talks About

You import one button from a design system library. Your bundle grows by 200KB. You only used Button, but you got every icon, every utility, every theme, and every component the library exports. Your users pay the download tax on every page load.

This is the barrel file problem, and it is shockingly common in component libraries. Tree shaking — the bundler's ability to remove unused code — only works when your library is structured to allow it. Most libraries accidentally prevent tree shaking through one innocent-looking file: index.ts.

Mental Model

Imagine a warehouse that ships products. Tree shaking is the warehouse deciding to only load the products the customer actually ordered onto the truck. But if every product is chained together on a single pallet (a barrel file re-exporting everything), the warehouse cannot separate them — the whole pallet goes on the truck. To make tree shaking work, each product must be independently reachable.

How Tree Shaking Works

Tree shaking is a bundler feature (webpack, Rollup, esbuild, Vite) that removes unused exports from the final bundle. It works through static analysis of ESM import/export statements.

// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export function complexCalculation() {
  // 500 lines of code
}

// app.js
import { add } from './math.js';
console.log(add(1, 2));

The bundler sees that only add is imported. It marks multiply and complexCalculation as unused and removes them from the output. Your bundle only contains the add function.

Requirements for tree shaking to work:

  1. ESM formatimport/export, not require/module.exports. CommonJS cannot be statically analyzed.
  2. No side effects — the module must not execute code on import. If importing a module runs global setup code, the bundler cannot safely remove it.
  3. Static imports — dynamic import() works but is handled differently (code splitting, not tree shaking).
Quiz
Why can CommonJS (require/module.exports) not be tree-shaken?

The Barrel File Problem

A barrel file re-exports everything from a directory:

// components/index.ts (the barrel)
export { Button } from './Button';
export { Input } from './Input';
export { Select } from './Select';
export { Dialog } from './Dialog';
export { Tabs } from './Tabs';
export { Table } from './Table';
export { DatePicker } from './DatePicker';  // imports dayjs (60KB)
export { RichTextEditor } from './RichTextEditor';  // imports Prosemirror (150KB)
export { Chart } from './Chart';  // imports D3 (80KB)

Now a consumer writes:

import { Button } from '@acme/ui';

What actually happens depends on the bundler. In theory, the bundler should tree-shake and only include Button. In practice:

  1. The bundler resolves @acme/ui to the barrel file
  2. It evaluates all the export statements
  3. If any re-exported module has side effects, the bundler keeps it
  4. If the bundler is not confident about side effects, it keeps everything to be safe

The result: importing Button might pull in DatePicker's dayjs dependency, RichTextEditor's Prosemirror, and Chart's D3. Your consumer's bundle balloons.

Why Does This Happen?

// Button.tsx
import { cn } from '../utils/cn';  // side-effect free — tree shakable
import { theme } from '../theme';   // imports CSS? registers globals? unclear

export function Button() { ... }

If ../theme runs code on import (registers CSS, sets up global variables, modifies prototypes), the bundler cannot remove it. And because the barrel file chains Button through an export, the entire import chain must be preserved.

Common Trap

CSS imports are side effects. If your component does import './Button.css', that import runs code (injects a stylesheet). The bundler must keep it even if the component is tree-shaken away. This is why CSS-in-JS and CSS Modules need the sideEffects field configured correctly — so the bundler knows which imports are just CSS (keep them) vs which are pure JavaScript (safe to remove).

The sideEffects Field

The sideEffects field in package.json tells the bundler which files are safe to remove if their exports are unused:

{
  "name": "@acme/ui",
  "sideEffects": false
}

"sideEffects": false means: "Every file in this package is side-effect free. If you do not use an export from a file, you can safely remove the entire file."

For packages with CSS, you need to be more specific:

{
  "name": "@acme/ui",
  "sideEffects": [
    "**/*.css",
    "**/*.scss",
    "./src/setup.ts"
  ]
}

This says: "CSS files and setup.ts have side effects (keep them). Everything else is safe to tree-shake."

Quiz
Your library has sideEffects: false in package.json. A consumer imports Button, which internally imports a CSS file (import './Button.css'). What happens to the CSS?

Per-Component Entry Points

The most reliable way to ensure tree shaking works is to bypass the barrel file entirely. Give each component its own entry point:

{
  "name": "@acme/ui",
  "exports": {
    "./button": {
      "types": "./dist/button/index.d.ts",
      "import": "./dist/button/index.mjs",
      "require": "./dist/button/index.cjs"
    },
    "./input": {
      "types": "./dist/input/index.d.ts",
      "import": "./dist/input/index.mjs",
      "require": "./dist/input/index.cjs"
    },
    "./dialog": {
      "types": "./dist/dialog/index.d.ts",
      "import": "./dist/dialog/index.mjs",
      "require": "./dist/dialog/index.cjs"
    }
  }
}

Consumers import directly:

import { Button } from '@acme/ui/button';
import { Input } from '@acme/ui/input';

Now there is zero ambiguity. The bundler resolves @acme/ui/button directly to the Button module. No barrel file, no re-export chain, no accidental inclusion of DatePicker's 60KB dependency.

The Trade-Off: Convenience vs. Bundle Size

// Convenient but risky (barrel)
import { Button, Input, Select } from '@acme/ui';

// Safe but verbose (per-component)
import { Button } from '@acme/ui/button';
import { Input } from '@acme/ui/input';
import { Select } from '@acme/ui/select';

The best approach: support both. Keep the barrel for convenience, but ensure each component is also available via its own entry point. Consumers who care about bundle size use per-component imports. Consumers who do not care use the barrel.

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs"
    },
    "./button": {
      "types": "./dist/button/index.d.ts",
      "import": "./dist/button/index.mjs"
    }
  }
}
Key Rules
  1. 1Ship ESM (import/export) — CommonJS cannot be tree-shaken
  2. 2Set sideEffects in package.json — include CSS files, exclude everything else
  3. 3Provide per-component entry points via the exports field for guaranteed tree shaking
  4. 4Keep a barrel file for convenience but document that per-component imports are smaller
  5. 5Test actual bundle impact in CI — do not assume tree shaking works, verify it

Bundle Size Testing in CI

Trust but verify. Add automated bundle size checks to your CI pipeline:

Using size-limit

{
  "size-limit": [
    {
      "name": "Button only",
      "path": "dist/button/index.mjs",
      "limit": "5 KB"
    },
    {
      "name": "Full bundle",
      "path": "dist/index.mjs",
      "limit": "150 KB"
    },
    {
      "name": "Tree-shaken Button via barrel",
      "import": "{ Button }",
      "path": "dist/index.mjs",
      "limit": "8 KB"
    }
  ]
}
# .github/workflows/size.yml
jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - uses: andresz1/size-limit-action@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}

This posts a comment on every PR showing exactly how the change affects bundle size:

Package size report:
  Button only: 3.2 KB (+0.1 KB)
  Full bundle: 142 KB (+2.3 KB)
  Tree-shaken Button: 5.1 KB (+0.1 KB)

Using bundlejs.com

For quick checks during development, bundlejs.com lets you test tree shaking online:

https://bundlejs.com/?q=@acme/ui&treeshake=[{Button}]

This shows the actual gzipped size of importing just Button from your published package.

Quiz
Your Button component is 3KB. After adding it to the barrel file (index.ts), a consumer imports it via the barrel: import { Button } from '@acme/ui'. The consumer's bundle grows by 45KB. What is the most likely cause?

ESM-Only or Dual Format?

Should your library ship only ESM, or both ESM and CommonJS?

ApproachProsConsWhen to Use
ESM onlySimpler build, guaranteed tree shaking, smaller packageBreaks consumers still on require() or older Node.jsNew libraries, when all consumers use modern bundlers
Dual (ESM + CJS)Maximum compatibility, works everywhereComplex build config, potential dual-package hazardEstablished libraries with diverse consumer base
CJS onlySimple, works everywhereNo tree shaking, no top-level await, legacyNever for new libraries

The dual-package hazard: if a consumer imports your package via both ESM and CJS paths (directly and through a dependency), two copies of your code load. State is not shared, singletons break, and instanceof checks fail.

{
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

The exports field with separate import and require conditions lets Node.js and bundlers pick the right format automatically.

The Cost of Convenience Imports

Here is a real-world measurement. A popular component library (200+ components) with a single barrel export:

import { Button } from 'big-library';

// Without tree shaking:    580 KB (gzipped: 145 KB)
// With tree shaking (ideal): 4 KB (gzipped: 1.5 KB)
// With tree shaking (actual): 48 KB (gzipped: 14 KB)

// Why 48KB instead of 4KB?
// - Shared utils imported by Button: 8KB
// - Theme context provider (side effect): 12KB
// - Icon sprite sheet (side effect): 18KB
// - CSS reset (side effect): 6KB
// - Button component: 4KB

The "actual tree-shaken" size is 12x larger than the component itself because of side-effecting dependencies. Per-component entry points with correctly marked side effects bring it down to the ideal 4KB.

This is why bundle size testing is non-negotiable. The difference between "tree shaking should work" and "tree shaking actually works" can be 10x.

What developers doWhat they should do
Publishing only CommonJS (module.exports) and expecting tree shaking
CommonJS is evaluated at runtime. Bundlers cannot statically analyze require() calls to determine which exports are unused
Always ship ESM (import/export) — it is the only format that enables tree shaking
Setting sideEffects: false when your library imports CSS files
CSS imports are side effects. Marking them as side-effect free causes the bundler to remove them, and your components render without styles
Set sideEffects to an array that includes CSS patterns: ['**/*.css']
Only testing bundle size of the full library, not individual imports
The full bundle size tells you the worst case. The tree-shaken size tells you what consumers actually ship. If these are close, tree shaking is broken
Test both full bundle size AND tree-shaken size of individual component imports
Re-exporting heavy dependencies through the barrel file
A single barrel re-export of a component with a 100KB dependency can negate all tree shaking optimizations for every consumer
Isolate heavy components (charts, editors, date pickers) behind their own entry points