How to Fully Optimize Webpack 4 Tree Shaking

We reduced our bundle sizes by an average of 52%

Craig Miller
15 min readNov 5, 2019

--

Intro

A few months back I was tasked with upgrading my team’s React build configuration to Webpack 4. One of our main goals was to take advantage of tree-shaking, where Webpack strips out code that you’re not actually using to reduce your bundle size. Now, the benefits of tree shaking will vary depending on your codebase. Because of several architectural decisions in ours, we had a LOT of code being pulled in from other libraries created at our company, of which we were only using a small fraction of.

I’m writing this article because properly optimizing Webpack is not straightforward. What initially was supposed to be enabling some simple magic turned into a month-long process of scouring the internet for guidance on a range of issues I ran into. I’m hoping that by putting it all here others will have an easier time putting this together.

The Benefits

Before going into the technical details, let me summarize the benefits. Different applications will see different levels of benefits with this exercise. The main deciding factor is the amount of dead, tree-shakable code in your application. If you don’t have much, then you won’t see much of a benefit from tree-shaking. We had a LOT of dead code in ours.

In our department, the biggest problem was a number of shared libraries. These ranged from simple homegrown component libraries, to corporate standard component libraries, to giant blobs of code shoved together into a library for no rhyme or reason. A lot of it is tech debt, but a big problem was that all of our applications were importing all of these libraries, when in reality each one only needed small pieces of them.

Overall, once tree-shaking was implemented, our applications shrunk from between 25% to 75%, depending on the application. The average reduction across all of them was 52%, primarily driven by these bloated shared libraries being the majority of the code in small applications.

Again, mileage will vary, but if you feel you may have a lot of unneeded code in your bundles, this is how to get rid of it.

No Code Repo

I’m so sorry folks, the project I worked on is the property of my company, so I cannot share a link to a GitHub repo with the code. However, I will be providing stripped-down code examples throughout this article to illustrate the points I am making.

So, without further ado, let’s get into how to write the best tree-shakable webpack 4 configuration.

What is Considered Dead Code

This is simple: it’s code that Webpack can’t see you using. Webpack follows the trail of import/export statements throughout the application, so if it sees something being imported but ultimately not being used, it considers that to be “dead code”, and will tree-shake it.

Dead code isn’t always clear though. Below will be a few examples of dead code vs “live” code, which I hope will make this more clear. Keep in mind that there will be cases where Webpack will see something as dead code even though it really isn’t. Please see the section on Side Effects to learn how to handle this.

// Importing, assigning to a JavaScript object, and using it in the code below
// This is considered "live" code and will NOT be tree-shaken
import Stuff from './stuff';doSomething(Stuff);// Importing, assigning to a JavaScript object, but NOT using it in the code below
// This is considered "dead" code and will be tree shaken
import Stuff from './stuff';doSomething();// Importing but not assigning to a JavaScript object and not using it in the code
// This is considered to be "dead" code and will be tree shaken
import './stuff';doSomething();// Importing an entire library, but not assigning to a JavaScript object and not using it in the code
// Oddly enough, this is considered to be "live" code, as Webpack treats library imports differently from local code imports
import 'my-lib';doSomething();

Writing Tree-Shakable Imports

When writing tree-shakable code, your imports are important. You should avoid importing entire libraries into a single JavaScript object. When you do so, you are telling Webpack that you need the whole library, and Webpack will not tree shake it.

Take this example from the popular library Lodash. Importing the entire library at once is a big NO, but importing individual pieces is much better. Of course, Lodash specifically needs other steps to be tree-shaken, but this is a good first step.

// Import everything (NOT TREE-SHAKABLE)import _ from 'lodash';// Import named export (CAN BE TREE SHAKEN)import { debounce } from 'lodash';// Import the item directly (CAN BE TREE SHAKEN)import debounce from 'lodash/lib/debounce';

Basic Webpack Configuration

The first step to tree-shaking with Webpack is to write up your webpack config file. There are lots of customizations you can make to your webpack configuration, but the following items are required if you want to tree-shake your code.

First, you must be in production mode. Webpack only does tree shaking when doing minification, which will only occur in production model.

Second, you must set the optimization option “usedExports” to true. This means that Webpack will identify any code it thinks isn’t being used and mark it during the initial bundling step.

Lastly, you need to use a minifier that supports dead code removal. This kind of minifier will recognize how Webpack has marked up the code it thinks isn’t being used, and will strip it away. The TerserPlugin, the recommended minifier at the time of writing this article, supports this.

Here is a basic summary of the minimal required configuration for Webpack Tree Shaking:

// Base Webpack Config for Tree Shakingconst config = {
mode: 'production',
optimization: {
usedExports: true,
minimizer: [
new TerserPlugin({...})
]
}
};

What Are Side Effects

Just because Webpack can’t see a piece of code being used, doesn’t mean it can be safely tree-shaken. Some imports, simply by being included in the bundle, have an important impact on the application. A good example of this are global stylesheets, or a JavaScript file that sets up some global configuration.

Webpack considers files like these to have “side effects”. A file with side effects should NOT be tree-shaken, as this will damage the application as a whole. The designers of Webpack, clearly recognizing the risks of bundling code without being aware of which files have side effects, treat all code by default as having side effects. This protects you from having necessary files stripped out, but it means the default behavior of Webpack is, in fact, to not tree shake anything.

Fortunately, we can configure our project to tell Webpack that it is side-effect free and is ok to tree shake.

How to Tell Webpack Your Code is Side-Effect Free

There is a special property in the package.json called “sideEffects” that exists just for this purpose. It has three possible values:

“true” is the default value if you don’t specify otherwise. It means all the files have side effects, which means that none of them are tree-shakable.

“false” tells Webpack that none of the files have side effects, and all of them are tree shakable.

“[…]” an array of file paths is the third option. It is telling webpack that none of your files have side effects, except for the ones included in the array. Therefore every other file can be safely tree-shaken, except for these that are specified.

Every project you want to be tree shaken must have the “sideEffects” property set to either “false” or an array of file paths. In the case of my company’s work, both our base applications and all of those shared libraries I mentioned all needed their “sideEffects” flag configured properly.

Here is some code examples of the sideEffects flag. Despite the JavaScript comments, this is JSON code:

// All files have side effects, and none can be tree-shaken{
"sideEffects": true
}
// No files have side effects, all can be tree-shaken{
"sideEffects": false
}
// Only these files have side effects, all other files can be tree-shaken, but these must be kept{
"sideEffects": [
"./src/file1.js",
"./src/file2.js"
]
}

Global CSS and Side Effects

First, let’s define global CSS in this context. Global CSS is a stylesheet (could be CSS, SCSS, LESS, etc) that is directly imported into a JavaScript file. It is NOT converted into a CSS Module or anything like that. Basically, the import statement looks like this:

// Global CSS importimport './MyStylesheet.css';

So if you implement the side effects changes mentioned above, you’ll immediately notice one nasty problem if you run a webpack build. Any stylesheets imported in the manner above are now gone from the output. This is because an import like this is seen as dead code by webpack, and gets stripped out.

Fortunately, there is a simple solution to get past this. Webpack controls the loading of various types of files using it’s module rules system. Each rule for each type of file has its own “sideEffects” flag. This overrides any previously set sideEffects flags for files that match the rule.

So, to preserve our global CSS files, we just need to set this special sideEffects flag to true, like this:

// Webpack config for global CSS side effects ruleconst config = {
module: {
rules: [
{
test: /regex/,
use: [loaders],
sideEffects: true
}
]
}
};

This property exists on all module rules for Webpack. It MUST be used on the rules handling global stylesheets, regardless if it is CSS/SCSS/LESS/etc. It does NOT need to be used on CSS Modules in any of those formats.

What Are Modules, and Why Do They Matter

Now we start to enter the realm of “there be dragons”. On the surface, compiling to the right module type seems like an easy step, but as the next several sections will explain this is an area that leads to many complications. This is the part of the process that took me the longest to figure out.

First, we need to talk about modules. JavaScript has over the years developed the ability to effectively import/export code between files in the form of “modules”. There are many different JavaScript module standards that have existed over the years, but for the purpose of this guide, there are two we will be focusing on. One is “commonjs”, and the other is “es2015”. Here are what each of them look like in code form:

// Commonjs import/export module statementsconst stuff = require('./stuff');module.exports = stuff;// es2015 import/export module statementsimport stuff from './stuff';export default stuff;

By default, Babel assumes that we are writing code using es2015 modules and will transpile our JavaScript code to use commonjs modules instead. This is done for broad compatibility with server-side JavaScript libraries, which are usually built on top of NodeJS (NodeJS only supports commonjs modules). However, Webpack is NOT capable of tree shaking code with commonjs modules.

Now, there are plugins out there (such as common-shake-plugin) that purport to give Webpack the ability to tree-shake commonjs modules, but in my experience they either didn’t work or only had a tiny fraction of the impact that tree shaking can have when run against es2015 modules. I would NOT recommend these plugins.

So, to do tree-shaking, we need our code to compile to es2015 modules.

Configuring Babel For es2015 Modules

Babel does not, to my knowledge, support compiling other module systems into es2015 modules. However, I would say if you’re a frontend developer reading this, you’re probably already writing code using es2015 modules, as it is the recommended way across the board.

So, to have our compiled code use es2015 modules, all we need to do is tell babel to leave them alone. To accomplish this, we just add the following to our babel.config.js (you’ll see throughout this guide that I prefer JavaScript configuration over JSON configuration):

// Basic babel config for es2015 modulesconst config = {
presets: [
[
'@babel/preset-env',
{
modules: false
}
]
]
};

By setting modules to false, we are telling babel not to compile our module code. This will lead to babel preserving our existing es2015 import/export statements.

IMPORTANT: All tree-shakable code must be compiled in this way. So if you have libraries that you are importing pieces of, those libraries must be compiled to es2015 modules to be tree shaken. If they are compiled to commonjs, then they cannot be tree shaken and will be entirely bundled in your application. For many libraries, there are separate artifacts you can pull down. A good example is lodash, which ships with commonjs modules, but for which there is a lodash-es version with es2015 modules.

Moreover, if you have in-house libraries that you are using in your application, they must also be compiled using es2015 modules. To accomplish the reduction in our application bundle size, I had to get all those company libraries modified to be compiled this way.

And Now Jest Blows Up

This may apply to other testing frameworks, but Jest is what we were using.

Anyway, if you’ve gotten to this point, you will now find that your Jest tests will start failing. You may be horrified like I was when all sorts of strange new errors start flooding your logs. Well, don’t panic, I’ll walk you through this one too.

Why this is happening is simple: NodeJS. Jest is built on top of NodeJS, and NodeJS does not support es2015 modules. There are some ways to configure Node for this, but they don’t appear to work with jest. So now we’re stuck with Webpack requiring es2015 modules for tree shaking, but Jest not being able to run our tests with those modules.

This is why I said “there be dragons” with the module system. This is the one part of the process that took me the longest to figure out. I recommend reading this and the next few sections thoroughly, because I have a solution.

This solution has two main parts. The first is for local code, ie code in the project the tests are running in. That part is easy. The second part is for library code, as in code from other projects compiled to es2015 modules which you are importing into your local code. That is the more complicated part.

Fixing Your Local Code for Jest

Babel has a helpful feature for our problem: environments. It lets us configure settings to run in different environments. In this case, we need es2015 modules in development and production, but commonjs modules in test. Fortunately, this is very easy to configure in our Babel config:

// Babel config for separate environments, we move the "preset" to the "env" sectionconst config = {
env: {
development: {
presets: [
[
'@babel/preset-env',
{
modules: false
}
]
]
},
production: {
presets: [
[
'@babel/preset-env',
{
modules: false
}
]
]
},
test: {
presets: [
[
'@babel/preset-env',
{
modules: 'commonjs'
}
]
],
plugins: [
'transform-es2015-modules-commonjs' // Not sure this is required, but I had added it anyway
]
}
}
};

Once this is done, all of our local code will compile properly and run in our Jest tests again. However, any code from libraries we are importing that use es2015 modules will not.

Fixing Your Library Code for Jest

The reason the libraries are problematic is obvious with a single glance at our node_modules folder. Looking at the library code there, we will see the es2015 module syntax that we need for tree shaking. These libraries have already been compiled in this way, so when Jest tries to read them to run a unit test for one of our local code files, it will blow up. Interestingly, we’ve been able to enable commonjs modules for tests with Babel for our local code, yet that isn’t affecting these libraries. This is because, by default, Jest (specifically babel-jest) will ignore any code from node_modules when it is compiling our code prior to running the tests.

This is actually a good thing. If Jest were to recompile all our libraries when running tests it would drastically increase test processing time. However, while we don’t want it to recompile EVERYTHING, we do want it to recompile those libraries that are using es2015 modules, that way they can be used in our unit tests.

Thankfully, Jest offers us a solution in its own configuration. I will point out that this next part took me a minute to wrap my head around, and I feel it is unnecessarily complicated, but it is the only solution I’m aware of to this problem.

Configuring Jest to Recompile Libraries

// Jest configuration to recompile librariesconst path = require('path');const librariesToRecompile = [
'Library1',
'Library2'
].join('|');
const config = {
transformIgnorePatterns: [
`[\\\/]node_modules[\\\/](?!(${librariesToRecompile})).*$`
],
transform: {
'^.+\.jsx?$': path.resolve(__dirname, 'transformer.js')
}
};

The above configuration is what Jest requires to recompile your libraries. There are two main parts to it, I’ll explain them both.

Transform Ignore Patterns is a feature in the Jest configuration. It is an array of regex strings. Any code that matches these regexes will NOT be recompiled by babel-jest. By default the value is a single string, “node_modules”. This is the reason why Jest will not recompile any of our library code.

When we provide our own configuration, we are customizing how Jest IGNORES code when recompiling. That is why the crazy regex you see there has a negative look-ahead in it. It is designed to match against all code EXCEPT the libraries we want to recompile. Put another way, we are telling Jest to ignore everything in node_modules except for the specific libraries we want it to recompile.

This is another place where JavaScript configuration shines over JSON configuration, as I was able to use string interpolation to inject a joined array of library names easily into the regex.

The second thing you’ll notice is the Transform option. This points to a custom babel-jest transformer. I’m not 100% certain this one is necessary, but I added it anyway. It is configured to load our Babel configuration and use it when recompiling everything, including the libraries.

// Babel-Jest Transformerconst babelJest = require('babel-jest');
const path = require('path');
const cwd = process.cwd();
const babelConfig = require(path.resolve(cwd, 'babel.config'));
module.exports = babelJest.createTransformer(babelConfig);

With all of this done, your tests should be running fine again. Keep in mind that any es2015 module using libraries that you add need to be added to this Jest configuration to keep the tests working.

Npm/Yarn Link = The Devil

Now for the next pain point: linking libraries. The process of using npm/yarn link to link a library simply creates a symlink to the local project directory. It turns out that Babel throws a lot of errors when recompiling libraries that are symlinked this way. One of the reasons it took me so long to figure out the Jest stuff is because I had been linking all my libraries and seeing those errors.

The solution: do NOT use npm/yarn link with this configuration. Instead, use a tool like “yalc”, which allows you to connect local projects but perfectly mimics the normal npm installation process for the library. Not only does this not have the problems with Babel recompilation, but it does things like handling transitive dependencies way better.

Library-Specific Optimizations

If you do all of the above actions, you will have an application that is starting to really implement some robust tree shaking. However, to get even more out of your bundle size, there are other things you can do. I’m going to include some of the optimizations for specific libraries that I implemented here, but this is by no means an exhaustive list. It can especially serve as inspiration for some of the cool things we can do.

MomentJS is known to be a huge library. Fortunately, it is possible to trim it down by excluding some or all of its locales. In the code example below, I’m excluding all the locale data from momentjs and just using the base application, thus trimming down its size substantially.

// MomenJS with locales stripped out by IgnorePluginconst { IgnorePlugin } from 'webpack';const config = {
plugins: [
new IgnorePlugin(/^\.\/locale$/, /moment/)
]
};

Moment-Timezone is the cousin to MomentJS, and it is also quite huge. Its size primarily comes from a giant JSON file with timezone information. I found that just but trimming it down to only including years in this century I reduced its presence in my bundle by over 90%. This could be done with a special Webpack plugin just for this situation.

// MomentTimezone Webpack Pluginconst MomentTimezoneDataPlugin = require('moment-timezone-data-webpack-plugin');const config = {
plugins: [
new MomentTimezoneDataPlugin({
startYear: 2018,
endYear: 2100
})
]
};

Lodash is another big one that can bloat up our bundle. Fortunately, there is an alternative package, Lodash-es, which is compiled to es2015 modules and has the sideEffects flag. Using Lodash-es instead of Lodash further drives down the bundle size.

In addition, Lodash-es, react-bootstrap, and other libraries can be helped by the Babel transform imports plugin. This plugin takes import statements that pull from a library’s index.js and make them point to specific files in the library. This then makes it easier for webpack to tree shake the library when parsing the module tree. The example below should demonstrate how this works.

// Babel Transform Imports// Babel configconst config = {
plugins: [
[
'transform-imports',
{
'lodash-es': {
transform: 'lodash/${member}',
preventFullImport: true
},
'react-bootstrap': {
transform: 'react-bootstrap/es/${member}', // The es folder contains es2015 module versions of the files
preventFullImport: true
}
}
]
]
};
// Full imports of these libraries are no longer allowed and will cause errorsimport _ from 'lodash-es';// Named imports are still allowedimport { debounce } from 'loash-es';// However, these named imports get compiled by Babel to look like this// import debounce from 'lodash-es/debounce';

Conclusion

And there you have it. Optimizations like these can lead to drastically smaller bundle sizes. As frontend architecture starts moving in new directions (such as micro-frontends), keeping your bundle size optimized becomes more important than ever. I hope this guide proves helpful to folks seeking to implement tree shaking in your own applications.

--

--