Tree-shakable library design
Ship ESM with named exports, mark `sideEffects: false` (or list the files that have them), avoid import-time side effects, prefer many small pure modules over barrels that pull everything, don't re-export huge namespaces, and keep modules pure so bundlers can drop what consumers don't use.
Designing a tree-shakable library means structuring it so a consumer's bundler can confidently drop the parts they don't import.
The requirements
1. Ship ESM with named exports. Tree shaking relies on ES modules being statically analyzable. Ship an ESM build (alongside CJS) and use named exports — export function debounce() — not a single default-exported object bundling everything (which can't be partially dropped).
2. Mark sideEffects: false in package.json. This is the explicit signal: "importing any module in this package has no side effects, so if an export isn't used, it's safe to remove the whole module." Without it, bundlers are conservative and keep modules "just in case." If some files do have side effects (a CSS import, a polyfill), list them: "sideEffects": ["*.css", "./src/polyfill.js"].
3. Actually be side-effect-free. The flag is a promise — the code must keep it. No work at import time: don't mutate globals, don't run code at module top-level, don't register things on import. Side effects are exactly what blocks the bundler from removing a module.
4. Many small pure modules, careful barrels.
- Keep functionality in small, focused modules.
- A barrel file (
index.jsre-exporting everything) is fine if every module is side-effect-free andsideEffects: falseis set — then unused re-exports get dropped. If not, the barrel drags everything in. - Avoid re-exporting giant namespaces.
5. Don't force namespace imports. Design so consumers can do import { thing } from "lib" and get just thing. Avoid APIs that only work via import * as Lib.
6. Keep modules pure and decoupled. Pure functions, minimal cross-module coupling — so dropping one export doesn't accidentally require keeping ten others.
How to verify
- Build a test app that imports one thing, inspect the output bundle (or use a bundle analyzer) — confirm the rest didn't come along.
- This is exactly why
lodash-esexists: ESM, per-function modules, side-effect-free — soimport { debounce }ships onlydebounce.
The framing
"Tree-shakability means a consumer's bundler can safely drop what they don't import. So: ship ESM with named exports — not one default object — because tree shaking needs static analyzability. Mark sideEffects: false so the bundler isn't conservative, and actually be side-effect-free — no work at import time, no global mutation, since side effects are what block removal. Structure it as many small pure modules; barrels are fine only if everything's side-effect-free. And design the API so named imports work — don't force import * as. Then verify with a bundle analyzer that importing one thing doesn't drag the rest."
Follow-up questions
- •What does `sideEffects: false` actually tell the bundler?
- •Why do import-time side effects block tree shaking?
- •When is a barrel file safe vs harmful for tree shaking?
- •How would you verify your library is actually tree-shakable?
Common mistakes
- •Shipping only CJS — not tree-shakable at all.
- •A single default-exported object bundling the whole API.
- •Not setting sideEffects, so bundlers keep everything conservatively.
- •Setting sideEffects: false while actually having import-time side effects.
- •Forcing namespace imports.
Performance considerations
- •This IS a performance discipline — done right, consumers ship only the code they use; done wrong, your whole library lands in their bundle, hurting their load time and TTI.
Edge cases
- •A module that legitimately has a side effect (CSS import, polyfill).
- •Barrel files re-exporting side-effectful modules.
- •Consumers using a bundler that doesn't tree-shake well.
- •Class-based APIs being harder to shake than functions.
Real-world examples
- •lodash-es: per-function ESM modules, side-effect-free — import only what you use.
- •Icon libraries shipping each icon as its own export so unused icons drop out.