DEV Community

Cover image for Tree Shaking in JavaScript - A Practical Guide
Md. Isfar Uddin
Md. Isfar Uddin

Posted on

Tree Shaking in JavaScript - A Practical Guide

Imagine you're building a web app and your production bundle is absolutely massive. Like, embarrassingly large. 😅

You keep wondering: "Why am I shipping 500KB of JavaScript when my app only uses maybe 200KB of actual code?" It felt like I was sending my users an entire library when they only needed a single book.

That's when I realized—I wasn't using tree shaking properly. And man, was I missing out on some serious optimization!

What Even is Tree Shaking?

Okay, so tree shaking sounds fancy, but it's actually a simple concept. Imagine you're pruning a tree. You shake the branches, and all the dead leaves fall off. The healthy, green leaves stay attached. That's exactly what tree shaking does in JavaScript—it removes dead code (code you're not using) and keeps only the code your app actually needs.

Tree Shaking Concept

The technical term is dead code elimination. During the build process, your bundler analyzes your code and says: "Hey, this function is never called anywhere. Let me remove it from the final bundle."

Boom. ✨ Your bundle gets smaller.

Why Should You Care?

I get it—you might think: "My app works fine, who cares about a few extra kilobytes?" But here's the thing:

Smaller bundles = Faster downloads. Think about someone on a 4G connection downloading your app. Every KB matters. A 500KB bundle vs. a 300KB bundle is literally 40% extra data transferred. That's noticeable. That's waiting. That's users closing your tab and going to a competitor. 😬

Performance matters for business. Studies show that every 100ms delay in load time costs you users. Tree shaking isn't magic, but it helps.

It's free optimization. You don't have to rewrite your code. Your bundler does the work for you in production.

Bundle Size Optimization

Pretty sweet, right?

The Problem with CommonJS (Why It Doesn't Work)

Here's where it gets tricky. Not all module systems support tree shaking. And this is where many developers make a big mistake.

You might write CommonJS code like this:

// utils.js
exports.add = (a, b) => a + b;
exports.multiply = (a, b) => a * b;
exports.subtract = (a, b) => a - b;
exports.divide = (a, b) => a / b;
Enter fullscreen mode Exit fullscreen mode

Then in your app:

// app.js
const utils = require('./utils');
console.log(utils.add(5, 3)); // Only add!
Enter fullscreen mode Exit fullscreen mode

Here's the problem: they only used add. But bundler still included everything! Why?

Because CommonJS is dynamic. The bundler can't look at require('./utils') and know exactly what you'll use. You might access utils.multiply at runtime through a variable or something sneaky. So bundlers play it safe and include the entire module.

It's like ordering a pizza but the restaurant insists on giving you the entire pizza box, the menu, the napkins, the garlic knots you didn't order... 🍕

// This is what ends up in your bundle
// even though you only used add()
exports.add = (a, b) => a + b;         Used
exports.multiply = (a, b) => a * b;    Dead code
exports.subtract = (a, b) => a - b;    Dead code
exports.divide = (a, b) => a / b;      Dead code
Enter fullscreen mode Exit fullscreen mode

Frustrating, right?

ES6 Modules Are Different (And Way Better)

This is where ES6 modules shine. They're static, which means the bundler can analyze them at build time and figure out exactly what's needed.

// math.js (ES6)
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;
export const subtract = (a, b) => a - b;
export const divide = (a, b) => a / b;
Enter fullscreen mode Exit fullscreen mode

Now in your app:

// app.js
import { add } from './math';
console.log(add(5, 3));
Enter fullscreen mode Exit fullscreen mode

The bundler sees: "Only add is imported. I can safely remove the others."

And it does. multiply, subtract, and divide never make it to the final bundle. 🎉

// This is what ends up in your bundle with tree shaking
export const add = (a, b) => a + b;         Used
// multiply, subtract, divide removed!
Enter fullscreen mode Exit fullscreen mode

This is tree shaking in action. Clean. Efficient. Beautiful.

ES6 vs CommonJS

The import * Mistake (Don't Do This)

Many developers do this when they're learning:

import * as math from './math';
console.log(math.add(5, 3));
Enter fullscreen mode Exit fullscreen mode

Looks clean, right? Super convenient! Wrong. ❌

When you import everything with *, you're basically telling the bundler: "I need ALL of these exports, I promise!" So tree shaking throws its hands up and says, "Fine, I'll include everything."

This is a common mistake when looking at bundles—finding hundreds of unused functions from libraries that were imported. It's like showing up to a party and inviting everyone on the guest list, even though you only know three people.

The right approach:

// Good ✅
import { add, subtract } from './math';
console.log(add(5, 3));
console.log(subtract(10, 3));

// Bad ❌
import * as math from './math';
console.log(math.add(5, 3));
Enter fullscreen mode Exit fullscreen mode

Named imports are your friend. They let tree shaking do its job properly.

Side Effects Are Tricky (This One Caught Me Off Guard)

Here's something that catches many developers off guard. Consider a module that looks like this:

// logger.js
console.log("Logger started!");  // <-- Side effect!

export const log = (msg) => console.log(msg);
export const warn = (msg) => console.warn(msg);
export const error = (msg) => console.error(msg);
Enter fullscreen mode Exit fullscreen mode

That console.log at the top? That's a side effect. It runs when the module loads, no matter what you import from it.

Now when you import just log:

import { log } from './logger';
log("Hello world");
Enter fullscreen mode Exit fullscreen mode

The bundler thought: "I can't remove this module because it has a side effect. That console.log might be critical!"

So even though you don't use warn or error, they stayed in the bundle. 😞

The fix? Move the side effect into a function:

// logger.js (Fixed!)
const initLogger = () => {
  console.log("Logger started!");
};

export const log = (msg) => console.log(msg);
export const warn = (msg) => console.warn(msg);
export const error = (msg) => console.error(msg);

// Call this only when you need it
initLogger();
Enter fullscreen mode Exit fullscreen mode

Now if you don't import warn or error, they get removed. No side effects blocking the way! 🎯

Side Effects

How to Actually Enable Tree Shaking

If you're using Webpack, tree shaking happens automatically in production mode. But you can be explicit about it:

// webpack.config.js
module.exports = {
  mode: 'production',  // This enables tree shaking
  optimization: {
    usedExports: true,
    sideEffects: false
  }
};
Enter fullscreen mode Exit fullscreen mode

Or tell it in your package.json:

{
  "name": "my-package",
  "sideEffects": false  // "I promise, no side effects!"
}
Enter fullscreen mode Exit fullscreen mode

Rollup and Vite enable tree shaking by default in production builds. So if you're using those, you're already good to go.

Real Talk: Verify It Actually Works

Theory is nice, but you should always verify tree shaking is working in your project. Use webpack-bundle-analyzer to visualize your bundle:

npm install --save-dev webpack-bundle-analyzer
Enter fullscreen mode Exit fullscreen mode

Then run it:

webpack-bundle-analyzer dist/main.js
Enter fullscreen mode Exit fullscreen mode

It generates a cool visualization showing exactly what's in your bundle. You might be shocked when you first see it—there could be 10KB of unused code from libraries you completely forgot about!

It's like looking in your closet and realizing you've got clothes with the tags still on them from three years ago. Time to clean up! 🧹

Common Mistakes I Made (So You Don't Have To)

Mistake 1: Using import *

// 😫 Bad
import * as lodash from 'lodash';
const debounced = lodash.debounce(myFunc, 1000);
Enter fullscreen mode Exit fullscreen mode

You'd be bundling half the library. The better approach:

// ✅ Good
import { debounce } from 'lodash';
const debounced = debounce(myFunc, 1000);
Enter fullscreen mode Exit fullscreen mode

Result: Cut bundle size by ~40KB immediately.

Mistake 2: Forgetting Dependencies Have Dependencies

Library A imports from Library B, which imports from Library C. If you import anything from A, all of it stays in your bundle, even if you don't use it. It's a chain reaction.

This is why you see packages like lodash-es (ES6 version) being recommended—better tree shaking support!

Mistake 3: Not Using Production Builds

Tree shaking only happens in production. If you're checking your dev bundle, tree shaking hasn't run yet. Many developers spend hours trying to debug this before realizing dev bundles are supposed to be big. 🤦‍♂️

Mistake 4: Assuming All Libraries Support Tree Shaking

Not every package is built with ES6 modules. Some still use CommonJS. Check the package.json:

{
  "main": "dist/index.js",        // CommonJS - no tree shaking
  "module": "dist/index.es.js"    // ES6 - tree shaking works!
}
Enter fullscreen mode Exit fullscreen mode

If there's no "module" field, tree shaking won't work for that package.

What Actually Happens During Tree Shaking

Your bundler does two things:

  1. Marking phase: Traces through all your imports and marks everything that's actually used with a little checkmark ✅
  2. Removal phase: Takes a deep breath and deletes everything that wasn't marked ❌

It's like walking through your codebase with a highlighter, marking everything you use, then having a cleanup crew throw away the rest.

The Bundle Size Transformation

Here's what could happen in your project:

Before tree shaking: 450KB
After named imports:  320KB
After removing *:     285KB
After package.json:   250KB

Result: 44% smaller! 🎉
Enter fullscreen mode Exit fullscreen mode

That's the difference between a 5-second load and a 3-second load for users on slower connections.

The Bottom Line

Tree shaking is powerful because it's automatic. Once you understand the rules, it just works:

  • ✅ Use ES6 modules (import/export)
  • ✅ Import only what you need (named imports)
  • ✅ Keep modules side-effect free
  • ✅ Use production builds for shipping
  • ✅ Check your bundle to verify it's working

That's it. Your bundler handles the rest.

If you refactor your imports and remove those import * statements, your production bundle could drop from 450KB to 250KB. That's real impact. That's real performance gains.

Give tree shaking a shot in your next project. Your users will thank you. Your lighthouse score will thank you. Your bundles will be lean and mean. 💪


Have you had bundle size issues before? What did you do to fix it? Drop a comment below and let's talk!

Also, go check your bundle with webpack-bundle-analyzer. I bet you'll find some surprises!

Happy coding! 🚀

Top comments (0)