Tree-Shakable Library Exports
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.
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:
- ESM format —
import/export, notrequire/module.exports. CommonJS cannot be statically analyzed. - 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.
- Static imports — dynamic
import()works but is handled differently (code splitting, not tree shaking).
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:
- The bundler resolves
@acme/uito the barrel file - It evaluates all the
exportstatements - If any re-exported module has side effects, the bundler keeps it
- 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.
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."
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"
}
}
}
- 1Ship ESM (import/export) — CommonJS cannot be tree-shaken
- 2Set sideEffects in package.json — include CSS files, exclude everything else
- 3Provide per-component entry points via the exports field for guaranteed tree shaking
- 4Keep a barrel file for convenience but document that per-component imports are smaller
- 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.
ESM-Only or Dual Format?
Should your library ship only ESM, or both ESM and CommonJS?
| Approach | Pros | Cons | When to Use |
|---|---|---|---|
| ESM only | Simpler build, guaranteed tree shaking, smaller package | Breaks consumers still on require() or older Node.js | New libraries, when all consumers use modern bundlers |
| Dual (ESM + CJS) | Maximum compatibility, works everywhere | Complex build config, potential dual-package hazard | Established libraries with diverse consumer base |
| CJS only | Simple, works everywhere | No tree shaking, no top-level await, legacy | Never 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 do | What 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 |