The Brutal Reality of "Dead Code"
Let's be honest: most developers treat tree shaking like some kind of dark magic that happens automatically because they use Webpack or Vite. You assume that if you don't call a function, it won't end up in your production bundle. That is a lie, or at the very least, a massive oversimplification. In reality, your bundle is likely bloated with "ghost code"—functions and classes that are never executed but remain bundled because your build tool is too scared to delete them. Tree shaking is not a passive feature; it is an active architectural choice that requires you to understand how static analysis works and where it inevitably fails.
Static analysis is the engine behind tree shaking. When you use ES Modules (ESM), the bundler can trace the "graph" of your imports and exports without actually running the code. However, the moment you introduce ambiguity, the bundler reverts to a "safety first" mentality. If the tool cannot prove with 100% certainty that a piece of code is unused and has no side effects, it stays. This is why you see 50KB of a utility library in your main chunk when you only used a single slugify function. It's not the tool's fault; it's usually yours for writing code that is impossible to analyze statically.
The industry has moved toward ESM, yet we still cling to patterns that break the very optimizations we claim to value. We wrap things in high-order components, use dynamic keys, or rely on legacy libraries that haven't updated their build targets in five years. If you want a lean application, you have to stop treating your bundler like a janitor and start treating it like a precision instrument. This means auditing your dependencies and understanding that one "convenient" barrel file could be the reason your lighthouse score is in the gutter.
The Silent Killer: Side Effects and Scared Bundlers
The most common reason tree shaking fails is the presence of side effects. A side effect is essentially any code that does something when it's imported, even if you never call it. Think of a global polyfill, a console.log at the top level of a file, or a script that modifies window.analytics. When a bundler encounters a module, it asks: "If I skip this module, will the application break?" If you have code at the top level of your file that isn't wrapped in a function, the bundler assumes the answer is "yes." It doesn't matter if you didn't import anything from that file; if it's in the dependency chain, it's staying.
To fix this, you must use the sideEffects property in your package.json. This is the single most powerful hint you can give to tools like Webpack or Rollup. By setting "sideEffects": false, you are making a legal promise to the compiler that none of the files in your package do anything crazy upon import. If you don't use a function from a file, the compiler is now legally allowed to ignore that entire file. However, being "brutally honest" means admitting that most developers forget this, or worse, they set it to false when they actually do have side effects, causing cryptic bugs where global styles or polyfills mysteriously vanish in production.
The 80/20 Rule of Tree Shaking Efficiency
If you apply the Pareto Principle to bundle optimization, 80% of your bloat likely comes from 20% of your dependencies. You don't need to spend hours micro-optimizing your internal helper functions if you are still importing the entire lodash library instead of lodash-es. The quickest wins come from identifying large, monolithic packages that do not support ESM. CommonJS (CJS) is the natural enemy of tree shaking. Because require() calls are dynamic and can happen inside if statements, bundlers cannot reliably determine what is being used at build time.
The second part of that 20% is the "Import All" anti-pattern. While import * as Utils from './utils' looks clean, it often creates a single object that holds references to everything in that file. In some older or less sophisticated build pipelines, this prevents the compiler from plucking out individual functions. Even if modern bundlers are getting better at handling this, why risk it? Use named imports. It's more explicit, it's easier to grep, and it gives your bundler the best possible chance to do its job without having to guess your intentions or perform expensive object tracking.
Finally, pay attention to your "barrel files"—those index.js files that just re-export everything from a folder. They are a developer experience (DX) dream but a performance nightmare. When you import one item from a barrel file, you often inadvertently pull in the dependency tree of every other file exported by that index. This creates a massive web of imports that can confuse even the best static analyzers. If you must use barrel files, ensure your project is strictly ESM and that your sideEffects flags are perfectly configured, or you'll find your "small" feature branch dragging the entire component library into the user's browser.
Transpilation: How Babel Might Be Sabotaging You
You might be writing beautiful, modern TypeScript with ESM imports, but what is your compiler actually outputting? This is a deep dive into the "Transpilation Trap." Many developers use Babel or TypeScript to target older browsers, and in the process, they inadvertently convert their ESM import/export statements into CommonJS require/module.exports. Since tree shaking happens after transpilation in many legacy pipelines, the bundler receives a CJS mess that it can no longer shake. You think you're being modern, but your build tool is seeing code that looks like it was written in 2014.
To avoid this, you must ensure that your compiler (Babel or tsc) leaves ES modules alone. In TypeScript, this means setting "module": "ESNext" in your tsconfig.json. In Babel, it means setting { "modules": false } in your @babel/preset-env configuration. This allows the transpiler to handle the syntax (like arrow functions or optional chaining) while leaving the module structure intact for the bundler to optimize. If you miss this step, all your efforts to use named imports are essentially useless. It's like trying to filter water with a sieve that you've accidentally lined with plastic wrap—nothing is getting through.
// Good: Static imports that can be analyzed
import { formatCurrency } from './formatters';
export const displayPrice = (amount: number) => {
return formatCurrency(amount);
};
// Bad: Dynamic or side-effect heavy patterns
const moduleName = 'utils';
const utils = require(`./${moduleName}`); // Tree shaking is now impossible here
export default {
calculate: (a: number, b: number) => a + b
};
// Default exports are harder to shake than named exports!
Furthermore, you need to be aware of how "class" transpilation works. When Babel converts an ES6 class to a function, it often adds "helper" decorators or IIFEs (Immediately Invoked Function Expressions) to the code. These IIFEs are frequently flagged by bundlers as having potential side effects. If you have a large library of classes, you might find that none of them can be tree-shaken because the transpiler wrapped them in a protective bubble of "just-in-case" execution code. Modern tools like SWC or Esbuild are better at this, but if you're stuck on an older stack, you're likely fighting a losing battle against your own build tools.
Analogies to Make It Stick
Think of tree shaking like a move to a smaller apartment. If you just throw everything into boxes without looking, you'll end up paying for a massive moving truck and a storage unit for stuff you haven't touched in years. ESM is like labeling those boxes clearly: "Kitchen," "Books," "Old Gym Clothes." A "Side Effect" is like a box labeled "Random Junk" that might contain your passport; you can't throw it away because you're not sure if you need it to survive. If you want a cheap move (a small bundle), you need to be ruthless about what gets boxed up in the first place.
Another way to visualize this is a high-end restaurant menu versus a massive 20-page diner menu. A diner menu (CommonJS/Non-shakable code) requires the kitchen to have every possible ingredient ready at all times, just in case someone orders the blueberry pancakes at 11 PM. A curated tasting menu (ESM/Shakable code) only stocks exactly what is on the list for that night. If you aren't serving the duck, the duck isn't in the kitchen. If you want your app to be a lean, Michelin-star experience, stop building it like an "everything-included" diner where the kitchen is overflowing with rotting ingredients.
5 Key Actions for Immediate Results
If you are tired of bloated bundles, follow these non-negotiable steps to reclaim your performance. This isn't a "maybe" list; it's a "must-do" list for any serious frontend engineer.
- Audit with Visualizers: Run
webpack-bundle-analyzerorrollup-plugin-visualizer. If you see a giant block for a library you only use once, you have a tree shaking failure. - Switch to ESM-first Libraries: Replace
lodashwithlodash-es,date-fns, orlucide-react. Always checkbundlephobia.combefore adding a new dependency. - Fix
package.jsonFlags: Ensure your internal libraries and your main project have the"sideEffects": falseflag where applicable. This is the "easy button" for optimization. - Check Transpilation Targets: Verify that your
tsconfig.jsonor.babelrcisn't converting your imports to CommonJS. Usenpm listto check for duplicate versions of libraries. - Prefer Named Exports: Stop using
export default. Named exports are more resilient to refactoring and significantly easier for bundlers to track through complex dependency trees.
Conclusion: Stop Dreaming, Start Auditing
Tree shaking is not a "set it and forget it" feature. It is a fragile process that relies on the discipline of the developer and the quality of the ecosystem. Being brutally honest, most projects are failing at it. We trade performance for the convenience of import * or the familiarity of legacy libraries, and our users pay the price in load times and battery drain. The tools are there, but they require you to provide them with the right hints and the right code structure to succeed.
If you take nothing else away, remember that code is a liability, not an asset. Every line of code that makes it into your production bundle should have a reason for being there. If you can't prove it's being used, and your bundler can't prove it's safe to remove, you are failing the "shaking" test. Audit your bundles today, look for those giant squares in your visualizer, and start cutting the dead weight. Your users won't thank you—because they'll be too busy enjoying a fast app to notice—but your Lighthouse scores certainly will.