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:

  1. Start from entry points (main.js, route files, dynamic import() targets).
  2. Follow static import and export statements to build a module graph.
  3. Mark every export that is referenced (directly or re-exported) as live.
  4. 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.json exports field for each icon.
  • Use sideEffects: false on leaf icon files so unused siblings prune cleanly even through a barrel.
  • In Webpack 5, enable optimization.sideEffects: true (default) and verify module.rules do 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.

  1. Split sprite registration into an explicit import '@harbor/icons/register' call only on admin routes.
  2. Published per-icon deep exports: @harbor/icons/cart, marked each with sideEffects: false.
  3. Updated storefront imports to leaf paths; added an ESLint rule banning barrel imports from @harbor/icons in customer-facing apps.
  4. 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_modules destroy 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 via exports; 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 module entry.
  • Audit imports for namespace and default imports of large libraries.
  • Confirm sideEffects is accurate on internal packages.
  • Replace barrel imports with deep paths for icon and utility libraries.
  • Run ESLint no-restricted-imports for 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 sideEffects field 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