JavaScript tree shaking, like a pro
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:
- You declare all of your imports and exports for each of your modules
- Your bundler (Webpack, Rollup, and so on) analyzes your dependency tree during the compilation step
- Any provably unused code is dropped from the final bundle, or ‘tree-shaken’.
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.
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.
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.
Instead, try to keep exports as small and simple as possible:
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:
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 theadd
function… - I have no idea what
window.memoize
does, but I know there’s a possibility it could calladd
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:
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 inutil.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!