Blog · Performance & Optimization
Tree Shaking Guide 2026: Eliminate Dead Code from Bundles
How tree shaking works and how to make it actually work — ES modules, the sideEffects flag (and its CSS gotcha), avoiding barrel files, pure annotations and verifying with an analyzer — each with why and a code example.
Tree shaking is how modern bundlers remove code you import but never use, so it never ships to the browser. Done right it can shave a large chunk off your bundle for free — done wrong (one wrong config line) it silently does nothing. This guide explains how tree shaking works in 2026 and 14 things that make it actually eliminate dead code, each with the reasoning and a code example.
1. Understand what tree shaking can and can't do
Tree shaking is static dead-code elimination: the bundler builds a graph of ES import/export statements, marks what’s reachable, and drops the rest. Because it’s static, it can’t remove code chosen at runtime (dynamic property access on a namespace, conditional require). Structure code so usage is statically analyzable.
// ✅ statically analyzable — unused exports get dropped
import { formatDate } from "./utils";
// ❌ dynamic access defeats analysis — everything is "used"
import * as utils from "./utils";
utils[name]();
2. Use ES modules, not CommonJS
Tree shaking only works on ES modules because their imports/exports are static and analyzable at build time. CommonJS (require/module.exports) is dynamic, so bundlers must keep it whole. Author your code and prefer dependencies in ESM; check that a library ships an "module"/"exports" ESM entry before relying on it being shakeable.
// ✅ ESM — shakeable
export function a() {}
export function b() {}
import { a } from "./lib"; // b is dropped
// ❌ CommonJS — kept whole
module.exports = { a, b };
3. Set the sideEffects flag — and allowlist CSS
By default a bundler must assume importing a module might have side effects (registering a global, injecting a style), so it can’t drop it even if you use nothing from it. Setting "sideEffects": false promises your modules are pure, unlocking aggressive removal. The classic gotcha: CSS imports *are* a side effect — a blanket false can strip your styles. Allowlist them.
// package.json — pure JS, but keep style imports
{
"sideEffects": ["**/*.css", "**/*.scss", "./src/polyfills.js"]
}
4. Avoid barrel files and re-export hubs
A barrel (index.js that re-exports everything) makes importing one thing pull the whole barrel into the graph; if any module in it has side effects, tree shaking stalls. Import from the specific source file instead. Large internal barrels are a common reason "tree shaking isn’t working."
// ❌ imports the whole barrel graph
import { Button } from "@/components";
// ✅ import the specific module
import { Button } from "@/components/Button";
5. Prefer named imports from tree-shakeable libraries
Named imports from a properly ESM-packaged library let the bundler take only what you reference. Default-exporting a giant object (older lodash, moment) can’t be shaken. Prefer libraries that ship ESM and per-function entry points — e.g. lodash-es over lodash, date-fns (fully modular) over moment.
// ✅ only debounce is bundled
import { debounce } from "lodash-es";
// ❌ CommonJS default export → whole library kept
import _ from "lodash";
6. Keep modules free of top-level side effects
Code that runs at import time — mutating globals, calling console.log, registering listeners at the top level — is a side effect the bundler must preserve, which can pin otherwise-dead code. Keep module top level to declarations; do work inside functions that are called, not on import.
// ❌ runs on import — a side effect, can't be shaken away
window.analytics = init();
// ✅ export a function; the caller decides
export function initAnalytics() { window.analytics = init(); }
7. Use /*#__PURE__*/ for function-call values
When you assign the result of a function call at module top level, the bundler can’t know the call is side-effect-free, so it keeps it. A /*#__PURE__*/ annotation tells it the call can be dropped if its result is unused. Libraries use this; you can too for factory-style top-level constants.
export const icon = /*#__PURE__*/ createIcon("home");
// if `icon` is never imported, the createIcon call is removed
8. Build in production mode
Tree shaking and the dead-code elimination that finalizes it run in production builds, not dev. In development, bundlers keep everything for fast rebuilds and readable output. Always verify your deploy runs the production build (vite build, webpack --mode production) — a dev build ships everything.
vite build # production: tree-shaken + minified
# not `vite` / `vite dev`, which is unoptimized on purpose
9. Don't let transpilation downgrade ESM to CommonJS
If Babel/TypeScript is configured to emit CommonJS, your nice ES modules become un-shakeable before the bundler sees them. Set the module target to preserve ESM ("module": "esnext", "target": "esnext" in tsconfig; don’t force CJS in Babel) and let the bundler handle the final format.
// tsconfig.json — keep ESM for the bundler to shake
{
"compilerOptions": { "module": "esnext", "target": "es2022" }
}
10. Verify tree shaking actually happened
Tree shaking fails silently — a misconfigured sideEffects or a CJS dependency just means nothing is removed, with no error. Confirm it worked by inspecting the output: search the built bundle for a symbol you know is unused, or open a bundle analyzer treemap and check the module isn’t there.
# is an unused export still in the bundle?
grep -R "unusedExportName" dist/assets/ && echo "NOT shaken"
# or inspect visually
npx source-map-explorer dist/assets/*.js
11. Lazy-load instead of forcing a library to shake
Some libraries simply aren’t tree-shakeable (monolithic, CJS, heavy side effects). Rather than fighting it, keep it out of the initial bundle with a dynamic import() so it loads only when the feature is used. Splitting beats shaking when a dependency refuses to shrink.
async function exportPdf() {
const { jsPDF } = await import("jspdf"); // loaded on demand only
new jsPDF().save("out.pdf");
}
12. Combine with minification for full dead-code elimination
Tree shaking removes unused *exports*; the minifier’s dead-code elimination removes unreachable code *within* modules (an if (false) branch, a function never called locally). They’re complementary — you want both. Production builds run them together, which is another reason to always ship the production build.
// the minifier drops this at build time
if (process.env.NODE_ENV !== "production") {
expensiveDevOnlyCheck(); // eliminated in prod
}
13. Check your framework/library docs for shakeable imports
Many popular libraries have a documented tree-shakeable import path or a Babel/SWC plugin that rewrites imports to per-module paths (icon sets, UI kits, utility libraries). Following their guidance is often the difference between importing 5 KB and 500 KB. Read the "reducing bundle size" section of your key dependencies.
// example: import icons individually, not the whole set
// ❌ import { Home } from "icon-lib"; // may pull all icons
// ✅ import Home from "icon-lib/icons/Home"; // just one
14. Re-check after every dependency change
Adding, upgrading or swapping a dependency can quietly reintroduce a non-shakeable module or a barrel import that undoes your gains. Wire a bundle analyzer or size-limit check into CI so a regression in tree-shaking shows up on the pull request that caused it, not weeks later in production.
// size-limit in CI catches shaking regressions
// .size-limit.json
[{ "path": "dist/assets/index-*.js", "limit": "160 KB" }]
Tree shaking checklist
- Use ES modules everywhere; don’t let transpilation emit CommonJS.
- Set
"sideEffects"honestly — allowlist CSS so styles survive. - Avoid barrel files; import specific modules and use named imports.
- Keep module top level free of side effects; annotate pure calls with
/*#__PURE__*/. - Always build in production; combine tree shaking with minification.
- Prefer shakeable libraries; lazy-load the ones that refuse to shrink.
- Verify removal with an analyzer, and guard against regressions in CI.
Frequently asked questions
Why is tree shaking not working?
index.js import pulls in side-effectful modules, sideEffects isn’t set, or you’re inspecting a development build. Build in production, use ESM, set "sideEffects" (allowlisting CSS), and verify with a bundle analyzer.What does "sideEffects": false do?
false can strip your styles, so allowlist them: "sideEffects": ["**/*.css"].Does tree shaking work with CommonJS?
import/export of ES modules to analyze usage at build time. CommonJS require is dynamic, so bundlers keep those modules whole. Use ESM in your code and prefer dependencies that ship an ESM build (for example lodash-es instead of lodash).What’s the difference between tree shaking and code splitting?
See how imports affect your output live in XCODX Studio. See also How to Reduce Bundle Size, the Code Splitting Guide and Lazy Loading Best Practices.