loke.dev
Header image for 4 Signs Your 'Tree-Shaking' Strategy Is Actually Doing Nothing

4 Signs Your 'Tree-Shaking' Strategy Is Actually Doing Nothing

Stop assuming your bundler is cleaning up your unused modules; learn how to identify the specific code patterns that are secretly breaking your tree-shaking.

· 4 min read

4 Signs Your 'Tree-Shaking' Strategy Is Actually Doing Nothing

I once spent an entire Tuesday staring at a Webpack bundle analyzer, trying to figure out why a simple "Contact Us" form was dragging 400KB of library code into the main chunk. I had all the right settings in my config, yet the dead code was clinging to my bundle like a stubborn barnacle.

It’s easy to treat tree-shaking as a "set it and forget it" feature, but bundlers like Webpack, Rollup, and Vite aren't magicians. They are incredibly paranoid accountants. If there is even a 0.1% chance that deleting a piece of code will break your app, they’ll leave it in.

Here are four signs that your tree-shaking strategy is failing and how to actually fix it.

1. You’re still "Swiss Army Knifing" your exports

If you have a utils.js file that exports one giant object containing every helper function in your project, you have already lost.

When you export a default object, the bundler sees it as a single unit. Even if you only use formatDate, the bundler has to include the whole object—and everything that object references—to ensure the integrity of the code.

The "Bad" Pattern:

// utils.js
export default {
  formatDate: (d) => { /* ... */ },
  generateHugeReport: (data) => {
    // This pulls in a heavy library like PDFKit
    import('pdfkit').then(...) 
  }
}

// index.js
import utils from './utils';
console.log(utils.formatDate(new Date())); 
// Surprise! You just bundled a PDF generator.

The Fix: Use named exports. This allows the bundler to see exactly which functions are being "touched."

// utils.js
export const formatDate = (d) => { /* ... */ };
export const generateHugeReport = (data) => { /* ... */ };

// index.js
import { formatDate } from './utils';

2. Your 'Side Effects' are scaring the bundler

Bundlers are terrified of code that does something just by being imported. This is known as a side effect. If a module modifies a global variable, touches the DOM, or sets up a listener at its top level, the bundler cannot safely remove it.

I see this often in CSS-in-JS libraries or "initialization" scripts.

// analytics.js
window.isAnalyticsLoaded = true; // This is a side effect.

export const trackEvent = (name) => {
  console.log(`Tracking ${name}`);
};

If you import trackEvent from this file, the bundler sees window.isAnalyticsLoaded = true; and thinks: *"I can't delete this file even if the function is unused, because someone might be checking that global variable!"*

The Fix: Use the sideEffects property in your package.json. By setting "sideEffects": false, you’re telling the bundler: *"I promise that none of my files do anything weird on import. If I don't use the exports, delete the whole file."*

If you have specific files that *do* have side effects (like CSS imports), you can provide an array:

{
  "name": "my-app",
  "sideEffects": [
    "*.css",
    "./src/initialize-sentry.js"
  ]
}

3. You're transpiling to CommonJS before the bundler sees it

Tree-shaking relies on the static nature of ES Modules (import and export). Unlike require(), which can be called inside an if statement or a function, import is fixed at the top of the file. This allows the bundler to map out the dependency graph without running a single line of code.

If you are using Babel to transpile your code into CommonJS (module.exports) before your bundler (Webpack/Rollup) gets to it, tree-shaking will break entirely.

The "Broken" Transpilation Output:

// Babel might turn your clean code into this:
var utils = require('./utils');
exports.myFunc = function() {
  return utils.formatDate();
};

CommonJS is dynamic. The bundler can't know for sure what require will return until the code actually runs, so it gives up and includes everything.

The Fix: Ensure your Babel or TypeScript config leaves ES Modules alone. In your .babelrc or babel.config.js, set modules: false:

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', { modules: false }]
  ]
};

4. You’re dealing with "Ghost Dependencies" in Classes

JavaScript classes are tricky. Because of how they are transpiled to support older browsers, they often look like they have side effects.

When you transpile a class, Babel often generates an IIFE (Immediately Invoked Function Expression). To a bundler, an IIFE is a black box that might be doing something important.

// What you wrote:
export class UIComponent {
  render() { console.log('rendering'); }
}

// What the bundler sees after transpilation:
var UIComponent = /*@__PURE__*/ (function () {
  function UIComponent() {}
  UIComponent.prototype.render = function () {
    console.log('rendering');
  };
  return UIComponent;
})();

See that /*@__PURE__*/ comment? That is a hint to the bundler that the following function is safe to delete if the result isn't used. If your tools aren't adding those comments, or if you are manually adding properties to a class prototype at the top level of a module, the class will stay in your bundle forever—even if it's never instantiated.

The Fix: Avoid heavy logic in class constructors or top-level prototype assignments. If you're building a library, favor functional patterns over heavy class hierarchies; they are inherently more "shakeable."

Checking your work

Stop guessing. If you suspect your bundle is bloated, run a visualizer tool.

For Webpack users, webpack-bundle-analyzer is the gold standard. For Vite/Rollup, rollup-plugin-visualizer is your best friend. Look for the "big blocks" that shouldn't be there. If you see a library you only used once taking up a massive chunk of space, go back and check for these four signs.

Tree-shaking isn't automatic—it's a partnership between you and your bundler. Stop breaking your end of the deal. #javascript