Tree Shaking Is a Lie: Why Your Bundle Still Ships Dead Code
The frontend community sold you on tree shaking as the silver bullet for bundle size. But your production bundles are still bloated with unused code. Here's why the promise was always incomplete - and what actually works.
The Pitch That Hooked an Entire Industry
Around 2015, Rollup arrived with a revolutionary pitch: tree shaking. Import only what you use, and the bundler will eliminate the rest. Dead code elimination, but automatic. No more bloated bundles shipping entire libraries when you only needed two functions.
Webpack adopted it. Vite built on it. Every modern bundler marketed it as table stakes. The industry collectively agreed: tree shaking solved bundle bloat. Ship ES modules, add "sideEffects": false to your package.json, and your bundle size problems would evaporate.
Except they didn't.
Ten years later, production bundles are still shipping hundreds of kilobytes of unused code. Developers add lodash-es instead of lodash, meticulously import named exports instead of default imports, and watch their bundle analyser still show entire dependency sub-trees they never touched. The promise of tree shaking was real - the execution was always incomplete.
"Tree shaking works perfectly, as long as your code is written in a way that makes tree shaking possible. That's the part nobody mentions in the tutorials."
The Seven Silent Killers of Tree Shaking
Tree shaking isn't a bundler feature you toggle on. It's a contract between your code, your dependencies, and the build tool. Break any part of that contract, and the entire mechanism collapses. Here's what actually kills tree shaking in production codebases.
1. Side Effects Anywhere in the Module Graph
The moment a module executes code at the top level - even something as innocent as console.log('loaded') or registering a global polyfill - the bundler can't safely eliminate it. Even if you import nothing from that module, it stays in the bundle. Why? Because JavaScript module semantics guarantee that module side effects execute once, in order, at load time. Removing the module would change program behaviour.
// utils/logger.js export const log = msg => console.log(msg); // This side effect forces the entire module into the bundle console.log('Logger utility loaded'); // Your code import { log } from './utils/logger'; // Even if you never call log(), the module ships.
The bundler sees that top-level console.log and assumes it's intentional. It can't prove removing the module wouldn't break something. So it keeps the whole file. This is why "sideEffects": false in package.json matters - it's you explicitly telling the bundler "this module is pure, you can safely remove unused exports".
2. CommonJS Modules (And the Libraries That Still Ship Them)
Tree shaking requires static analysis. ES modules give bundlers that - import and export are statically analyzable at build time. CommonJS require() is not. It's a runtime function call. The bundler can't determine what you're importing until the code runs.
Here's the kicker: most npm packages still ship CommonJS as their primary target. Even packages that claim to support tree shaking often include conditional requires or dynamic imports that force the bundler to include entire modules defensively.
find node_modules -name "package.json" -exec grep -l '"main"' {} \; and look at what the "main" field points to. If it's not an ES module (or if there's no "module" field pointing to one), tree shaking is dead on arrival for that dependency.
3. Re-Exports and Barrel Files
Barrel files (index.js files that re-export everything from a directory) are tree shaking poison. When you write export * from './moduleA', you're creating a hard dependency on moduleA's side effects, even if the consuming code only imports one symbol.
// components/index.js (the barrel) export * from './Button'; export * from './Modal'; export * from './Sidebar'; export * from './Header'; // Your code import { Button } from './components'; // Bundle includes Modal, Sidebar, Header too.
Why? Because the bundler evaluates export * as a dependency on all modules in the barrel, not just the one symbol you imported. Some modern bundlers (Vite, esbuild) handle this better than others (Webpack pre-5), but the safest pattern is still direct imports: import Button from './components/Button'.
4. Getters, Proxies, and Dynamic Property Access
If your module exports an object with getters, or uses a Proxy, tree shaking can't determine which properties are actually accessed at runtime. The bundler assumes all properties might be accessed and keeps the entire object.
// api.js export const api = { get user() { return fetchUser(); }, get posts() { return fetchPosts(); }, get comments() { return fetchComments(); }, }; // Your code import { api } from './api'; const user = await api.user; // Bundle includes fetchPosts and fetchComments too.
The bundler can't statically prove you're not accessing api.posts somewhere. Property access is dynamic in JavaScript. The only safe assumption is to keep everything.
5. Class Instantiation and Prototypes
When you export a class, tree shaking works - until someone instantiates it. At that point, the entire prototype chain must be included, even if the consuming code only calls one method. This is because JavaScript classes are syntactic sugar over prototypes, and the bundler can't eliminate methods from a prototype without breaking instanceof checks and inheritance.
6. String-Based Dynamic Imports
The moment you write const mod = await import('./module-' + name), tree shaking is gone. The bundler can't statically resolve which module you're loading, so it includes all possible candidates. This is why frameworks like Next.js and Nuxt warn against dynamic import paths that use variables.
7. Dependency Chains With Mixed Module Types
If Module A (ES module) imports Module B (CommonJS), which imports Module C (ES module), tree shaking breaks at the B boundary. Even if C is tree-shakable, the CommonJS layer in the middle creates an opaque dependency that the bundler can't see through. Your entire chain becomes un-shakable.
still ship CJS primary
on unused code (avg)
mark side effects
Why You Think It's Working (When It's Not)
Here's the insidious part: your build logs will show tree shaking happening. Webpack's stats, Vite's build output, Rollup's warnings - they all report "X modules tree-shaken". You see those numbers and assume success. But those numbers only reflect modules the bundler attempted to shake. They don't tell you about the modules it couldn't shake because of side effects three dependencies deep.
The bundle analyser is equally misleading. It shows you which packages contribute to bundle size, but it doesn't distinguish between "code you're actively using" and "code that was pulled in because of an unshakable side effect". A 50KB lodash entry in your analyser might be 5KB of functions you call and 45KB of functions pulled in by barrel exports.
To actually measure tree shaking effectiveness, you need to diff the bundle before and after removing an import. Add import { debounce } from 'lodash-es', build, note the bundle size. Remove the import, rebuild, note the new size. The difference is what you actually used. If it's smaller than the lodash-es size reported by the analyser, tree shaking failed.
Do this for five dependencies. You'll be horrified.
How Material-UI Breaks Tree Shaking (And Everyone Ships It Anyway)
Material-UI (MUI) is one of the most popular React component libraries. It's also a tree shaking disaster. Despite years of "we support tree shaking" claims in the docs, the reality is more complex.
MUI's core problem: the theme system. Every MUI component imports the theme provider, which imports the default theme object, which includes styles for every single component in the library - whether you use them or not. The theme object has getters, dynamic property access, and side effects (CSS-in-JS injection). It's un-shakable.
When you write import { Button } from '@mui/material', you're not importing just Button. You're importing Button + ThemeProvider + DefaultTheme + StylesEngine + every component's default styles. Your bundle grows by ~80KB gzipped, even if Button itself is 3KB.
// What you write import { Button } from '@mui/material'; // What actually gets bundled import Button from '@mui/material/Button'; import ThemeProvider from '@mui/system/ThemeProvider'; import createTheme from '@mui/material/styles/createTheme'; import defaultTheme from '@mui/material/styles/defaultTheme'; import styled from '@mui/system/styled'; // + 40 more transitive dependencies
The "fix" is to import from specific paths: import Button from '@mui/material/Button'. But even that doesn't eliminate the theme dependency - Button still imports it internally. The only way to actually tree-shake MUI is to use their experimental @mui/material-next package with manual theme configuration. Which means rewriting your entire component library integration.
MUI isn't an outlier. Ant Design has the same problem. Chakra UI has the same problem. Any component library with a theming system built on runtime context and CSS-in-JS will have this problem. It's not a bug - it's architecture.
The Strategies That Reduce Bundle Size (For Real)
If tree shaking is unreliable, what does work? Here are the approaches that produce measurable bundle size reductions in production codebases, ranked by impact.
1. Import From Specific Paths, Not Package Roots
Stop writing import { fn } from 'library'. Write import fn from 'library/fn'. Yes, it's verbose. Yes, it defeats the convenience of barrel exports. But it's the only import pattern that reliably avoids pulling in adjacent modules.
2. Audit Your Dependencies for CommonJS
Run npx publint on your dependencies. It'll report which packages ship dual ESM/CJS builds correctly and which don't. For packages that only ship CJS, find ESM alternatives or fork the package and convert it yourself. This sounds extreme - it's also the only way to make those dependencies shakable.
3. Use Build-Time Feature Flags Aggressively
Tools like Webpack's DefinePlugin and Vite's define config let you inject environment variables at build time. Use these to conditionally import entire modules based on environment. Dead code elimination (DCE) can remove those imports entirely if the condition is false at build time.
if (__DEV__) { import('./devTools').then(m => m.initDevTools()); } // In production build (where __DEV__ = false), // this entire block disappears, including the import.
4. Split Code by Route, Not by Library
Code splitting at route boundaries is far more effective than trying to tree-shake libraries. If a user never visits /admin, they never download the admin bundle. This is why framework-level code splitting (Next.js pages, Nuxt pages, Remix routes) produces bigger wins than any amount of tree shaking configuration.
5. Use Bundle Analyser Diffs, Not Absolute Sizes
After every dependency change, run npx vite-bundle-visualiser or webpack-bundle-analyzer and compare the output to the previous build. Look for unexpected size increases in unrelated chunks. That's your signal that tree shaking failed for a new dependency.
6. Replace Libraries With Browser APIs
This is the nuclear option, but also the most effective. Replace axios with fetch. Replace date-fns with Intl.DateTimeFormat. Replace lodash/debounce with a 10-line implementation. Every dependency you eliminate is one less tree shaking failure vector.
How Webpack, Rollup, Vite, and esbuild Actually Differ
Not all bundlers tree-shake equally. The differences matter more than the marketing claims.
Webpack (v5+)
Webpack's tree shaking improved dramatically in v5 with the addition of the sideEffects flag and better scope hoisting. But it's still conservative. Webpack will preserve any module with a top-level statement that isn't a pure function or export declaration. This includes imports of CSS files, JSON files, and any module that registers side effects in its parent scope.
Rollup
Rollup invented tree shaking and remains the most aggressive. It will eliminate unused exports even from modules with mild side effects, as long as those side effects are statically analyzable. But this aggressiveness is also a liability - Rollup has been known to eliminate code that looks unused but is actually invoked via dynamic references. Use it for libraries, not apps.
Vite (Rollup under the hood)
Vite uses Rollup for production builds, so it inherits Rollup's tree shaking behaviour. But Vite's dev server uses esbuild, which has weaker tree shaking. This means you might see different bundle sizes and behaviour between dev and prod. Always test tree shaking in prod builds.
esbuild
esbuild is the fastest bundler by far, but it trades tree shaking precision for speed. It only eliminates exports that are provably unused via static analysis. If there's any ambiguity - dynamic imports, computed property access, reflection - esbuild keeps the code. This makes esbuild bundles consistently larger than equivalent Webpack or Rollup bundles for complex apps.
Will This Ever Get Better?
The honest answer: not meaningfully, not soon.
Tree shaking is limited by the dynamic nature of JavaScript itself. As long as modules can have side effects, as long as property access is dynamic, as long as the module system allows runtime behaviour, bundlers will never achieve perfect dead code elimination. They'd have to run your entire program to prove what's unused - at which point you're not bundling, you're executing.
What might improve:
- Better package.json standards. The
sideEffectsfield is a start, but it's a blunt instrument (true/false/array of files). A more granular standard that marks individual exports as pure or impure could help. - Bundler hints. Developers could annotate imports with hints (
/* @__PURE__ */already exists for function calls). Expanding this to module imports could give bundlers permission to be more aggressive. - Build-time partial evaluation. Tools like Google Closure Compiler attempt to evaluate code at build time and eliminate unreachable branches. This is expensive and fragile, but as build machines get faster, it becomes viable for more projects.
The more likely future: we stop expecting tree shaking to solve bundle size and start designing libraries and frameworks that don't rely on it. Modular exports by default. Build-time extraction tools. Pre-compiled sub-packages. The Rust ecosystem does this well - cargo only links what you use because the compiler enforces it. JavaScript can't enforce it, so we need conventions.
What You Should Actually Do About This
If you're reading this on a production codebase, here's the priority order:
- Run a bundle analyser. Not to admire the visualisation - to identify your three largest dependencies. Check if they're tree-shakable. If not, they're your first candidates for replacement or refactoring.
- Audit
package.json"sideEffects" fields. If your own code isn't marking side effects correctly, fix it. If your dependencies aren't, open PRs or switch libraries. - Eliminate barrel exports. Convert
export * from './module'to individual named exports. Yes, it's tedious. Yes, it reduces bundle size by 10-20% on average. - Replace CommonJS dependencies. If a package only ships CJS and it's large, find an ESM alternative or write your own. This is especially true for utility libraries (date manipulation, validation, formatting).
- Add route-level code splitting. This has higher ROI than any tree shaking optimisation. If you're not using dynamic imports for routes, start there.
- Set up bundle size budgets in CI. Use tools like bundlesize or size-limit to fail builds when bundle size increases unexpectedly. This prevents tree shaking regressions from merging silently.
Tree Shaking Works. Just Not the Way You Think.
Tree shaking isn't a lie in the sense that it doesn't exist. It exists. It works. But it works under such strict conditions that it's effectively unreliable in real-world codebases with real-world dependencies. The mental model the industry sold - "use ES modules and tree shaking will save you" - was always incomplete.
The truth is closer to this: tree shaking is a best-case optimisation that degrades to zero the moment you introduce dynamic behaviour, side effects, or CommonJS anywhere in your dependency graph. It's not a feature you enable. It's a discipline you enforce.
The good news: the alternatives work. Direct imports, code splitting, dependency elimination, build-time feature flags - these aren't workarounds for broken tree shaking. They're the strategies that produce real, measurable bundle size reductions regardless of whether your bundler successfully shakes anything.
Stop trusting tree shaking. Start measuring what actually ships.
Webpack 5 Rollup Vite esbuild webpack-bundle-analyzer vite-bundle-visualiser publint size-limit
Related essays
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.
- Build Tools · Webpack
Your 100x Faster Rust Build Tool Is Slower Than Webpack. Here's Why.
Turbopack, SWC, and esbuild promise instant builds. But in real-world monorepos with warm caches and incremental compilation, Webpack still wins. Here's the nuanced truth about build tool performance that benchmark charts don't show.
- Security · Build Systems
Your Production Source Maps Are Leaking Your Entire Codebase to Anyone Who Knows Where to Look
Most frontend teams ship source maps to production without realising they're handing anyone a complete, readable copy of their application code. Here's what's actually inside them, how they get exploited, and what to do about it today.