JavaScript tree shaking, like a pro

Daniel Brain
7 min readJan 19, 2020

--

Tree shaking in JavaScript is becoming an essential practice, to avoid large bundle sizes and improve performance.

The principle behind tree shaking is as follows:

  1. You declare all of your imports and exports for each of your modules
  2. Your bundler (Webpack, Rollup, and so on) analyzes your dependency tree during the compilation step
  3. Any provably unused code is dropped from the final bundle, or ‘tree-shaken’.
This file exports two utility functions…
…but since only initializeName is actually used, formatName can be dropped entirely

Unfortunately, tree-shaking isn’t quite as simple as just enabling an additional flag in your Webpack config and allowing it to take care of everything. There are many things you need to check and consider to ensure the best possible tree-shaking is happening, and to make sure that tree-shaking is not being skipped entirely for any of your modules.

Getting Started

There are many other guides for getting started and setting up tree-shaking. Here’s a good starting point for Webpack.

For the sake of simplicity, a few years ago I set up the following boilerplate with many JavaScript build tools preconfigured and ready to go. This repo is also set up with tree-shaking out of the box; so if you need an example or a starting point, this may also be a good reference:

This article is geared towards Webpack, Babel, and Terser. That said, many of the principles listed here apply across the board, whether you’re using Webpack, Rollup, or anything else.

Use ES6 style imports and exports

Using import and export is the first essential step to allow tree-shaking to happen.

Most other module patterns, including commonjs and require.js, are non-deterministic at build time. This makes it impossible for bundlers like Webpack to determine exactly what is imported, exactly what is exported, and by extension, which code can be safely dropped.

All of the above are possible with commonjs

With ES6 style import and export statements, the capabilities of imports and exports are much more limited:

  • You can only import and export at the module level — not inside functions
  • Imports and exports must be static strings — no variables are allowed
  • Whatever you are importing, must actually be exported from somewhere.
ES6 imports and exports have far simpler semantics, and restricted usage.

These simplified rules allow bundlers to deterministically figure out exactly who is importing and exporting what code, and by extension, which code is not being used at all.

Don’t allow Babel to transpile imports and exports

The first problem you may run into is: if you’re using Babel to transpile your code, all import and export statements are, by default, transpiled down to commonjs. That forces Webpack to de-optimize, and fail to tree-shake.

Fortunately, this is a very straightforward thing to disable in your Babel config.

Once you’ve done that, Webpack will take over and transpile the imports and exports for you.

Make your exports granular and atomic

Webpack will generally leave exports fully intact. So if you’re:

  • Exporting an object with many properties and methods
  • Exporting a class with many methods
  • Using export default and including many things at once

Those exports will always be either fully included in the bundle, or fully tree-shaken. That means you may end up including a lot of code which is never used in the final bundle.

Here, both functions will be bundled, even if only one is ever used
And here, the entire class will be bundled any time it’s used

Instead, try to keep exports as small and simple as possible:

Now, if only one of these functions is ever used, only one will be bundled

This gives Webpack more of a mandate to throw away code, because now it can track at build time exactly which of these functions are being imported and used, or not used.

Following this practice has the added benefit of encouraging more of a functional and reusable coding style, along with discouraging the use of classes when they’re not providing explicit value.

If you’re interested in writing more functional-ish JavaScript, I cover that topic here:

Avoid module-level side effects

One big but very subtle problem that many people miss when writing modules is the impact of side-effects at the module scope:

Webpack has no idea what window.memoize will do here, so it can’t tree-shake the add function

Notice in the above example, that window.memoize will be called at the time when the module is imported.

Here’s how Webpack sees this:

  • OK, they’ve created a pure exported function called add — maybe I can remove this from the bundle, if nobody uses it later.
  • Now they’re calling window.memoize and passing in the add function…
  • I have no idea what window.memoize does, but I know there’s a possibility it could call add and trigger a side-effect.
  • So, to be safe, I’ll leave add in the bundle, even if I don’t see it being used anywhere else.

In reality, we probably know that window.memoize is a 100% pure function, which does not trigger any side-effects, and will only actually call add if somebody calls memoizedAdd.

But Webpack does not know that, and so to be safe it must include add in the bundle.

To be fair: The latest versions of Webpack and Terser do an extremely good job of detecting whether a side effect will actually be triggered or not. If we were to do the following instead:

Give Webpack as much information as you can, and you’ll get a better bundle

Now Webpack has enough information to run the following logical steps:

  • They’re calling memoize at the module level, that could be a problem
  • But memoize is an ES6 import, so let’s go take a look at that function in util.js
  • In reality, it looks like memoize is a pure function, so there’s actually no risk of any side-effects here
  • So if nobody uses add elsewhere in this codebase, we can safely drop it from the final bundle

However, in any cases where Webpack does not have enough information to make that decision, it takes the path of most safety, and refuses to tree-shake the function.

Use tooling to predict when a file can not be tree-shaken

There are two mechanisms using tooling I’ve found which really help here.

Firstly, I recommend using Webpack’s module concatenation plugin, which brings significant other performance benefits. That plugin comes with an option to debug concatenation bailouts on every build. Importantly, the same factors which prevent module concatenation (like module level side effects) also prevent tree-shaking. So treat any warnings from this module very seriously, since they will potentially result in bundle size increases.

Secondly, I recommend https://www.npmjs.com/package/eslint-plugin-tree-shaking. I haven’t integrated this module into grumbler yet, since the last time I checked it doesn’t support flow types; but when I experimented with it, it worked extremely well in picking up tree-shaking bailouts.

Be careful with libraries

When you can, use tree-shakeable versions of libraries. If you’re importing a big bundle of minified code from a library, like jquery.min.js, chances are that bundle will not be tree-shakeable. Better to find a module which gives you granular importable functions, then use Webpack or Rollup to bundle and minify everything.

Sometimes, you will need the entire bundle from a library. If you’re using React, for example, there’s virtually nothing shipped with the production build that you need to tree shake — everything included in the bundle is already as optimized as it can be.

But if you’re using a library that exports granular utility functions, like lodash, you should absolutely try to only import the functions you need and ensure the rest are tree-shaked.

Use build-time flags

A less well known feature of DefinePlugin in Webpack, is the ability to use it to decide which code to tree-shake during a build.

Now, if I pass __PRODUCTION__: true to DefinePlugin, not only will the call to validateOptions be dropped from the bundle, but the entire definition of the validateOptions function will be tree-shaked too.

This makes it very easy to create different bundles for development and production, and be sure any development-only code and debugging functions are totally dropped from the final production build.

Run a build

As a general rule of thumb: predicting how Webpack will behave for a given module, is extremely difficult to do by eye.

So run a build, create a bundle, check the bundle, and see what actually happened. Go in and look at the final bundle of code, to see if there’s anything in there which should have been tree-shaken but wasn’t.

Anything else?

Got any other good tree-shaking practices? Leave them in the comments, and I’ll add them here!

--

--