Guide
Tree shaking explained
Harbor Commerce's merchant dashboard imported a single icon from a
4,200-glyph UI kit — and the production bundle pulled in the entire
library. Bundle analysis showed 890 KB of SVG path data the checkout
page never rendered. The team had already adopted
code splitting
for admin routes; the remaining bloat was not lazy-loading failure but
tree shaking failure: dead exports that the bundler could
not prove were unused. Switching to per-icon ES module imports, marking
internal utility packages with accurate sideEffects metadata,
and replacing a barrel-file re-export layer cut the storefront's shared
vendor chunk by 41%. Tree shaking is dead-code elimination applied at
bundle time: the build tool walks your ES module import graph, determines
which exports are reachable from entry points, and drops everything else.
This guide covers static analysis requirements, the
sideEffects contract in package.json, barrel-file
and CommonJS pitfalls, library-specific import patterns, how Webpack,
Rollup, Vite, and esbuild differ, a Harbor Commerce refactor walkthrough,
a technique decision table, common mistakes, and a production checklist.
Tree shaking vs code splitting
These optimizations solve different problems and work best together. Code splitting divides your application into multiple output files loaded on demand — fewer bytes on the critical path for any single navigation. Tree shaking removes unused exports within each chunk so every file is as small as possible. A route split that lazy-loads an admin module still ships all of lodash if the module imports the full package; tree shaking fixes that. Conversely, perfect shaking of a monolithic entry still forces users to download every route's code up front. Start with shaking to eliminate obvious waste, then split what remains along route boundaries.
Both techniques depend on modern bundlers and ES module syntax. Legacy
require() graphs are far harder to analyze statically because
imports can be computed at runtime. If your dependency graph is still
CommonJS-heavy, shaking yields marginal gains until you migrate call sites
or switch to libraries that publish ESM builds.
How static analysis works
Rollup pioneered the term "tree shaking" (the unused branches fall off). The algorithm is conceptually simple:
- Start from entry points (
main.js, route files, dynamicimport()targets). - Follow static
importandexportstatements to build a module graph. - Mark every export that is referenced (directly or re-exported) as live.
- Drop statements and exports that are never marked live.
The bundler must be able to resolve imports at build time. Dynamic patterns break analysis:
// Bundler cannot know which submodule loads
const name = getIconName();
const icon = await import(`./icons/${name}.js`); // context module only
// Entire lodash included — namespace import
import * as _ from 'lodash';
_.debounce(fn, 300);
Prefer named imports from ESM entry points:
import debounce from 'lodash-es/debounce.js';
// or: import { debounce } from 'lodash-es';
The lodash-es package ships per-function modules Rollup and
esbuild can prune aggressively; the classic lodash CommonJS
build resists shaking.
The sideEffects flag
Some modules run code on import — polyfills, CSS injection, global
registration. If the bundler cannot prove a file is side-effect free, it
must retain the entire module even when no exports are used. Package
authors signal this in package.json:
{
"name": "@harbor/ui-icons",
"sideEffects": false
}
false tells the bundler: "if nothing imports from this
package, drop it entirely; if something imports one export, only keep
reachable code." For packages mixing side-effectful and pure files,
use an array of globs:
"sideEffects": [
"*.css",
"./src/polyfills.js"
]
When auditing your own monorepo packages, incorrect
sideEffects: false on a file that mutates globals will
silently delete initialization code in production. Test production builds,
not just dev server output. Tools like
webpack-bundle-analyzer and
rollup-plugin-visualizer show which modules survived shaking.
Barrel files and re-export traps
A barrel file re-exports many modules from one index:
// icons/index.ts — barrel
export { CartIcon } from './cart';
export { SearchIcon } from './search';
// ... 200 more
Application code imports one icon:
import { CartIcon } from '@harbor/ui-icons';
Depending on bundler settings and whether sibling exports have side effects, the build may include all icons because the barrel's top-level re-exports pull every submodule into the graph before the optimizer runs. Fixes:
- Import directly from leaf modules:
import CartIcon from '@harbor/ui-icons/cart'. - Configure deep exports in
package.jsonexportsfield for each icon. - Use
sideEffects: falseon leaf icon files so unused siblings prune cleanly even through a barrel. - In Webpack 5, enable
optimization.sideEffects: true(default) and verifymodule.rulesdo not disable it for your packages.
Internal app barrels (components/index.ts) cause the same
problem. Prefer direct paths or accept the cost when the barrel is small.
Bundler behavior at a glance
Rollup / Vite (production)
Vite uses Rollup for production builds. Rollup has the strongest tree
shaking implementation because it targets ESM natively. Ensure
build.rollupOptions.treeshake is not disabled and dependencies
resolve to their module field, not legacy
main CommonJS builds. See
Vite fundamentals
for output configuration.
Webpack 5
Webpack marks unused exports with optimization.usedExports: true
and relies on Terser (or esbuild minifier) to delete dead code in
production mode (mode: 'production' sets
sideEffects: true). Shaking is less aggressive than Rollup
for mixed CJS/ESM graphs. Use
resolve.mainFields: ['module', 'main'] and consider
babel-plugin-transform-imports for libraries without ESM.
Details in
Webpack fundamentals.
esbuild
esbuild performs fast syntax-level shaking — it removes unused exports within ESM files but does not execute the full cross-module dead-code propagation Rollup does. Still excellent for speed-first pipelines. Pair with manual import discipline. See esbuild fundamentals.
Library-specific patterns
| Library | Shakable import | Avoid |
|---|---|---|
| lodash | lodash-es/debounce or lodash/debounce |
import _ from 'lodash' |
| date-fns | import { format } from 'date-fns' |
Barrel importing all locales unless needed |
| moment | Migrate to date-fns or Luxon | moment bundles all locales by default |
| @mui/material | import Button from '@mui/material/Button' |
Deep barrel from root in older versions |
| icon libraries | Per-icon paths or SVGR single imports | Importing entire sprite sets |
For CSS, import only the stylesheets you need. A single
import 'bootstrap/dist/css/bootstrap.min.css' is not shaken
— it is a side effect by definition. Component-scoped CSS modules
or utility-first frameworks with purging (Tailwind's content scan)
address stylesheet bloat separately.
Worked example: Harbor Commerce icon refactor
Problem. Storefront bundle included 890 KB of icon
paths. Source showed import { CartIcon } from '@harbor/icons'
against a barrel exporting 4,200 glyphs. Webpack's analyzer listed every
.svg.js wrapper as a dependency of the vendor chunk.
Diagnosis. The icons package lacked
sideEffects: false. Several icon modules called
registerSprite() on import, forcing the bundler to retain all
siblings reachable through the barrel's static re-exports.
Changes.
- Split sprite registration into an explicit
import '@harbor/icons/register'call only on admin routes. - Published per-icon deep exports:
@harbor/icons/cart, marked each withsideEffects: false. - Updated storefront imports to leaf paths; added an ESLint rule banning
barrel imports from
@harbor/iconsin customer-facing apps. - Re-ran production build with
rollup-plugin-visualizer— vendor chunk dropped from 1.4 MB to 820 KB gzip.
Lesson. Tree shaking is a contract between your import style, package metadata, and bundler configuration. One impure side effect in a shared barrel can negate weeks of route-based code splitting.
Technique decision table
| Symptom | Likely cause | Fix |
|---|---|---|
| Entire utility library in bundle | Default or namespace import of CJS package | Switch to ESM build; per-function imports |
| All components from UI kit included | Barrel re-exports + side effects | Deep imports; sideEffects: false on leaves |
| Dev small, production huge | Dev server does not shake; prod misconfigured | Compare vite build / webpack --mode production |
| Missing runtime polyfill in prod | Over-aggressive sideEffects: false |
Whitelist polyfill files in sideEffects array |
| Locales bloating date lib | Locale imports not tree-shaken | Import only needed locales explicitly |
| Shaking works locally, not in CI | Different NODE_ENV or minifier disabled |
Align CI build flags with production |
Common pitfalls
- Trusting dev bundle size. Vite dev pre-bundles dependencies with esbuild for speed; shaking happens only in production builds.
- Namespace imports for convenience.
import * as icons from './icons'often prevents pruning even when you use one export. - Transpiling node_modules to CommonJS. Babel presets
that convert ESM deps to CJS in
node_modulesdestroy shaking — exclude dependencies from unnecessary transforms. - Class field side effects. Top-level
new Analytics()in a module body is a side effect; the whole file stays even if the export is unused. - Re-exporting everything. Public API barrels in libraries are ergonomic but shift burden to consumers; document deep import paths.
- Ignoring
package.json exports. Modern packages gate ESM vs CJS viaexports; wrong resolution path loads the non-shakable build. - Measuring minified gzip only. Parse time scales with live code after shaking; use coverage and Performance panels.
- Assuming shaking replaces splitting. Large but fully used modules still ship; combine both techniques.
Production checklist
- Generate a production bundle analysis report (visualizer or analyzer).
- Identify top five dependencies by parsed size after minification.
- Verify each heavy dependency resolves to its ESM
moduleentry. - Audit imports for namespace and default imports of large libraries.
- Confirm
sideEffectsis accurate on internal packages. - Replace barrel imports with deep paths for icon and utility libraries.
- Run ESLint
no-restricted-importsfor known bloated patterns. - Compare bundle size before and after each import refactor.
- Test production build in staging — verify no missing polyfills or CSS.
- Pair shaking wins with route-level code splits for remaining large modules.
Key takeaways
- Tree shaking removes unreachable exports at build time using static ES module analysis.
- It complements code splitting: shaking shrinks each chunk; splitting reduces how many chunks load initially.
- The
sideEffectsfield and import style determine whether the bundler can drop unused code. - Barrel files and CommonJS dependencies are the most common reasons shaking silently fails.
- Always validate production builds with a bundle analyzer — dev mode hides most shaking benefits.
Related reading
- Code splitting explained — dynamic imports, route chunks, and vendor splitting
- Vite fundamentals explained — Rollup production output and dependency pre-bundling
- Webpack fundamentals explained — usedExports, sideEffects, and splitChunks
- esbuild fundamentals explained — fast transforms and syntax-level shaking