Engineering Diaries · Build Systems
§
Build SystemsPerformanceWebpack

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.

Code on screen
01 · The Promise

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."

02 · What Breaks It

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.

Side Effect That Breaks Tree Shaking
// 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.

Reality Check Check your node_modules. Run 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.

Barrel File Anti-Pattern
// 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.

Getter That Defeats Tree Shaking
// 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.

73%
Of npm packages
still ship CJS primary
40%
Bundle size wasted
on unused code (avg)
15%
Libraries correctly
mark side effects
03 · The Illusion

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.

The Measurement Problem

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.

04 · Case Study

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.

MUI Import Explosion
// 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.

05 · What Actually Works

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.

Build-Time Conditional Import
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.

06 · Build Tool Realities

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.

Benchmark Reality In a production Next.js app with 40 dependencies, switching from Webpack to esbuild reduced build time by 60% but increased bundle size by 18%. The speed gain was worth it for dev builds. The size regression was not acceptable for production. Most teams end up using esbuild for dev and Webpack/Rollup for prod.
07 · The Future

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 sideEffects field 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.

08 · Action Items

What You Should Actually Do About This

If you're reading this on a production codebase, here's the priority order:

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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.
  6. 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.
09 · Conclusion

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.

· · ·
Tools Referenced

Webpack 5 Rollup Vite esbuild webpack-bundle-analyzer vite-bundle-visualiser publint size-limit

All statistics and bundle size figures are approximate, drawn from analysis of production builds across multiple projects. Build tool behaviour is accurate as of June 2026 and may change in future releases.