Tree Shaking Doesn’t Work (Most of the Time)
JS Edition
March 23, 2026

You install a library. You import one function. Your bundle grows by 500kb.
“But I only used debounce!” you scream at webpack.
Webpack shrugs. Lodash is in your bundle. All of it. Every function you didn’t use. Every utility you’ll never call. 500kb of JavaScript that does nothing.
This is tree shaking failing. And it fails more often than it works.
What Tree Shaking Is Supposed To Do
The promise is simple: import what you use, bundle only what you import.
// You write this:\nimport { debounce } from 'lodash';\n\n// You expect to bundle: just debounce (~2kb)\n// You actually bundle: all of lodash (~500kb)
Tree shaking is supposed to detect that you only imported debounce and shake off the rest of the “tree” - all the unused code falls away like dead leaves.
Except it doesn’t. Not reliably. Not for most npm packages.
Why It Fails: Side Effects
The problem is side effects. Code that does something when it runs, beyond just exporting functions.
// math.js\nexport function add(a, b) {\n return a + b;\n}\n\nexport function multiply(a, b) {\n return a * b;\n}\n\n// No side effects - just exports functions\n// Tree shakeable ✅
But this:
// analytics.js\nconsole.log('Analytics loaded!'); // Side effect!\n\nwindow.analytics = {\n track: function(event) {\n // ...\n }\n};\n\nexport function track(event) {\n window.analytics.track(event);\n}\n\n// Has side effects - modifies global scope\n// NOT tree shakeable ❌
When webpack sees that console.log and window.analytics =, it thinks: “This code runs when imported. I can’t remove it. Someone might depend on those side effects.”
So it bundles everything.
Even if you never call track(), the entire file stays in your bundle because webpack can’t prove it’s safe to remove.
The Real Culprit: package.json
Most npm packages don’t tell bundlers whether they have side effects.
Check any package’s package.json:
{\n "name": "some-library",\n "version": "1.0.0",\n "main": "index.js"\n // No "sideEffects" field\n}
No sideEffects field means webpack assumes every file has side effects. Everything gets bundled.
The fix is simple (for package authors):
{\n "name": "some-library",\n "version": "1.0.0",\n "main": "index.js",\n "sideEffects": false // This package is pure\n}
Or if only some files have side effects:
{\n "sideEffects": [\n "*.css",\n "*.scss",\n "./src/polyfills.js"\n ]\n}
But 80% of npm packages don’t do this. So tree shaking fails by default.
The CSS Disaster
Here’s a fun one. You mark your package as side-effect-free:
{\n "sideEffects": false\n}
Your component imports CSS:
// Button.js\nimport './Button.css'; // Styles for button\n\nexport function Button(props) {\n return <button className="btn">{props.children}</button>;\n}
You build for production. Tree shaking is enabled. You check your app.
The button renders. But it’s unstyled. The CSS is gone.
Why? Because you told webpack the package has no side effects. CSS imports are side effects. Webpack saw import './Button.css', saw "sideEffects": false, and removed it.
Your package.json lied. And your styles disappeared.
The fix:
{\n "sideEffects": [\n "**/*.css",\n "**/*.scss"\n ]\n}
Now webpack knows: “CSS files have side effects. Don’t remove them.”
How many packages get this wrong? Most of them.
The CommonJS Problem
Tree shaking only works with ES modules (import/export). It does NOT work with CommonJS (require/module.exports).
// ES modules - tree shakeable ✅\nimport { debounce } from 'lodash-es';\n\n// CommonJS - NOT tree shakeable ❌\nconst { debounce } = require('lodash');
Why? Because CommonJS is dynamic. You can do this:
const libName = Math.random() > 0.5 ? 'lodash' : 'underscore';\nconst lib = require(libName); // Determined at runtime
Webpack can’t analyze this at build time. It doesn’t know what libName will be. So it bundles everything.
ES modules are static. You can’t compute import paths:
// This doesn't work:\nconst libName = 'lodash';\nimport { debounce } from libName; // Syntax error
Because imports are static, webpack can analyze the dependency tree at build time and shake out unused code.
The problem: Even in 2026, tons of npm packages still ship CommonJS as their default.
Check package.json:
{\n "main": "index.js" // CommonJS\n "module": "index.mjs" // ES modules (if it exists)\n}
If there’s no "module" field, you’re getting CommonJS. Tree shaking won’t work.
The Barrel File Trap
You know those convenient index files?
// components/index.js (barrel file)\nexport { Button } from './Button';\nexport { Input } from './Input';\nexport { Select } from './Select';\nexport { Textarea } from './Textarea';\n// ... 50 more components
Then you import:
import { Button } from './components';
Seems elegant! But webpack bundles EVERYTHING from that barrel file.
Why? Because according to the ES module spec, all re-exported modules must be evaluated in case they have side effects.
You imported Button. But to know if the other 50 components have side effects, webpack has to load and evaluate all of them.
Result: You wanted Button. You got 50 components in your bundle.
The fix: Import directly:
// Instead of:\nimport { Button } from './components';\n\n// Do this:\nimport { Button } from './components/Button';
No barrel file. No accidental bundling. Tree shaking works.
Or, if you control the package, add "sideEffects": false to package.json and hope webpack trusts you.
Testing If Tree Shaking Works
Here’s how to check if your bundle actually tree-shook:
1. Check the bundle size:
npm run build\n\n# Check dist folder\nls -lh dist/\n# main.js 500kb (expected: 50kb)
If the bundle is way bigger than expected, tree shaking failed.
2. Inspect the bundle:
npx webpack-bundle-analyzer dist/stats.json
This shows you a visual map of your bundle. If you see lodash (all 500kb) when you only imported one function, tree shaking failed.
3. Search for unused exports:
# Build in development mode (more readable)\nnpm run build -- --mode development\n\n# Search for the function you DIDN'T import\ngrep -r "functionYouDidntImport" dist/\n# If it's there, tree shaking failed
4. Use webpack stats:
webpack --json > stats.json\n\n# Look for "harmony side effect evaluation"\n# This means webpack kept code due to side effects
Real-World Examples of Failure
Lodash:
import { debounce } from 'lodash'; // 500kb bundled\n\n// Fix:\nimport debounce from 'lodash/debounce'; // 2kb bundled\n// Or use lodash-es:\nimport { debounce } from 'lodash-es'; // Works with tree shaking
Moment.js:
import moment from 'moment'; // 300kb including ALL locales\n\n// Fix:\nimport moment from 'moment';\nimport 'moment/locale/en-gb'; // Only load what you need\n// Or switch to date-fns which tree-shakes properly
Material-UI (old versions):
import { Button } from '@material-ui/core'; // 500kb+\n\n// Fix (v4):\nimport Button from '@material-ui/core/Button';\n// Or use v5+ which tree-shakes better
RxJS (old versions):
import { Observable } from 'rxjs'; // Everything bundled\n\n// Fix (RxJS 6+):\nimport { Observable } from 'rxjs/Observable';
The Babel Trap
You’re using ES modules. You have "sideEffects": false. Tree shaking should work.
But Babel is converting your ES modules to CommonJS.
Check your .babelrc:
{\n "presets": [\n ["@babel/preset-env", {\n "modules": "commonjs" // ❌ Kills tree shaking\n }]\n ]\n}
Webpack sees CommonJS. Tree shaking fails.
The fix:
{\n "presets": [\n ["@babel/preset-env", {\n "modules": false // ✅ Keep ES modules\n }]\n ]\n}
Let webpack handle the modules. Don’t let Babel transform them.
Same problem with TypeScript:
// tsconfig.json\n{\n "compilerOptions": {\n "module": "commonjs" // ❌ Kills tree shaking\n }\n}
Fix:
{\n "compilerOptions": {\n "module": "esnext" // ✅ Keep ES modules\n }\n}
When Tree Shaking Actually Works
It’s not all doom. Tree shaking works perfectly when:
1. You control the code:
// Your own utility functions\nimport { formatDate } from './utils';\n// Works great - you wrote it, no side effects
2. Modern packages with proper config:
import { format } from 'date-fns';\n// date-fns has "sideEffects": false\n// Ships ES modules\n// Tree shakes perfectly
3. Direct imports:
import Button from './components/Button';\n// No barrel file, no ambiguity\n// Works every time
The sideEffects Field (For Package Authors)
If you publish packages, add this to your package.json:
{\n "sideEffects": false\n}
If you have CSS or polyfills:
{\n "sideEffects": [\n "**/*.css",\n "**/*.scss",\n "./polyfills.js"\n ]\n}
If you have side effects at the module level, mark them:
/*#__PURE__*/ someFunction();
The /*#__PURE__*/ comment tells webpack: “This function call is pure. Safe to remove if unused.”
This is your responsibility as a package author. If you don’t declare side effects, consumers can’t tree shake your package.
My Honest Take
I’ve debugged tree shaking failures in probably 20 different projects. Every time, it’s one of these:
-
Package doesn’t have
"sideEffects": false -
Package ships CommonJS by default
-
Barrel files loading everything
-
Babel/TypeScript converting ES modules to CommonJS
-
CSS imports not marked as side effects
Tree shaking works when everyone cooperates. Your code, the packages you use, your bundler config, your transpiler - everything has to be set up right.
And usually, something isn’t.
The result: You import one function, you bundle the entire library, your users download megabytes of unused code.
The fix is usually simple once you find the problem. But finding the problem requires understanding how tree shaking actually works.
Most developers don’t. They just see big bundles and shrug.
Quick checklist for tree shaking:
✅ Use ES modules (import/export)
✅ Set "sideEffects": false in package.json (or list files with side effects)
✅ Keep Babel/TypeScript from converting to CommonJS
✅ Avoid barrel files (or mark package as side-effect-free)
✅ Import directly when possible
✅ Use webpack-bundle-analyzer to verify
✅ Check if packages ship ES modules (look for "module" field)
❌ CommonJS modules
❌ No sideEffects declaration
❌ Babel converting to CommonJS
❌ Barrel files without side effect declarations
❌ CSS imports in packages marked as pure
Try it yourself:
// Install lodash\nnpm install lodash\n\n// Import one function\nimport { debounce } from 'lodash';\n\n// Build and check bundle size\nnpm run build\n\n// Surprised? You bundled all of lodash.\n\n// Fix:\nnpm install lodash-es\nimport { debounce } from 'lodash-es';\n\n// Build again. Much smaller.
Thanks for reading Under The Hood! Subscribe for free to receive new posts and support my work.