Tree shaking is a term commonly used in JavaScript for dead-code elimination. It relies on the static structure of ES2015 module syntax — that is, import and export. The name and concept were popularized by the ES2015 module bundler rollup.
webpack 2 introduced built-in support for ES2015 modules (also known as harmony modules) along with detection of unused module exports. webpack 4 expanded on this by letting you hint to the compiler, via the "sideEffects" property in package.json, which files in your project are "pure" and therefore safe to prune if unused.
[!TIP] The rest of this guide builds on Getting Started. If you haven't read through that guide yet, please do so now.
Let's add a new utility file to our project, src/math.js, that exports two functions:
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ ├── bundle.js
│ └── index.html
├── /src
│ ├── index.js
+ │ └── math.js
└── /node_modulesSet the mode configuration option to development to make sure the bundle is not minified:
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
+ mode: 'development',
+ optimization: {
+ usedExports: true,
+ },
};With that in place, let's update our entry script to use one of these new methods and remove lodash for simplicity:
- import _ from 'lodash';
+ import { cube } from './math.js';
function component() {
- const element = document.createElement('div');
+ const element = document.createElement('pre');
- // Lodash, now imported by this script
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.innerHTML = [
+ 'Hello webpack!',
+ '5 cubed is equal to ' + cube(5)
+ ].join('\n\n');
return element;
}
document.body.appendChild(component());Note that we did not import the square method from the src/math.js module. That function is what's known as "dead code": an unused export that should be dropped. Now let's run our npm script, npm run build, and inspect the output bundle:
/* 1 */
/***/ (function (module, __webpack_exports__, __webpack_require__) {
'use strict';
/* unused harmony export square */
/* harmony export (immutable) */ __webpack_exports__.a = cube;
function square(x) {
return x * x;
}
function cube(x) {
return x * x * x;
}
});Note the unused harmony export square comment above. If you look at the code below it, you'll notice that square is not imported, yet it is still included in the bundle. We'll fix that in the next section.
In a 100% ESM world, identifying side effects would be straightforward. We aren't there quite yet, however, so in the meantime we need to hint to webpack's compiler about the "purity" of your code.
This is done through the "sideEffects" property in package.json:
{
"name": "your-project",
"sideEffects": false
}None of the code above contains side effects, so we can set the property to false to tell webpack it can safely prune unused exports.
[!TIP] A "side effect" is code that performs special behavior when imported, beyond exposing one or more exports. Polyfills are an example: they affect the global scope and usually don't provide an export.
If your code does have side effects, you can provide an array instead:
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js"]
}The array accepts simple glob patterns to the relevant files. It uses glob-to-regexp under the hood (it supports *, **, {a,b}, and [a-z]). Patterns like *.css, which don't include a /, are treated like **/*.css.
[!TIP] Any imported file is subject to tree shaking. This means that if you use something like
css-loaderand import a CSS file, it must be added to the side-effects list so it isn't unintentionally dropped in production mode:
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}Finally, "sideEffects" can also be set from the module.rules configuration option.
The sideEffects and usedExports (better known as tree shaking) optimizations are two different things.
sideEffects is much more effective, because it allows webpack to skip whole modules/files and their complete subtree.
usedExports relies on terser to detect side effects in statements. That's a difficult task in JavaScript and not as effective as the straightforward sideEffects flag. It also can't skip subtrees or dependencies, because the spec requires side effects to be evaluated. While exporting a function works fine, React's Higher-Order Components (HOCs) are problematic in this regard.
If you're using dynamic import(), you can also use the webpackExports magic comment to specify which exports should be exposed, allowing webpack to tree shake the rest. See Magic Comments.
Let's look at an example:
import { Button } from '@shopify/polaris';The pre-bundled version looks like this:
import hoistStatics from 'hoist-non-react-statics';
function Button(_ref) {
// ...
}
function merge() {
const _final = {};
for (
let _len = arguments.length, objs = Array.from({ length: _len }), _key = 0;
_key < _len;
_key++
) {
objs[_key] = arguments[_key];
}
for (let _i = 0, _objs = objs; _i < _objs.length; _i++) {
const obj = _objs[_i];
mergeRecursively(_final, obj);
}
return _final;
}
function withAppProvider() {
return function addProvider(WrappedComponent) {
const WithProvider =
/*#__PURE__*/
(function (_React$Component) {
// ...
return WithProvider;
})(Component);
WithProvider.contextTypes = WrappedComponent.contextTypes
? merge(WrappedComponent.contextTypes, polarisAppProviderContextTypes)
: polarisAppProviderContextTypes;
const FinalComponent = hoistStatics(WithProvider, WrappedComponent);
return FinalComponent;
};
}
const Button$1 = withAppProvider()(Button);
export {
// ...,
Button$1,
};When Button is unused, you can effectively remove export { Button$1 };, which leaves all the remaining code intact. So the question becomes: "Does this code have any side effects, or can it be safely removed?" That's difficult to answer, especially because of this line: withAppProvider()(Button). withAppProvider is called and its return value is also called. Are there side effects when calling merge or hoistStatics? Are there side effects when assigning WithProvider.contextTypes (a setter?) or reading WrappedComponent.contextTypes (a getter?).
terser does try to figure this out, but in many cases it can't be sure. This doesn't mean terser isn't doing its job well; it's simply too difficult to determine reliably in a dynamic language like JavaScript.
But we can help terser with the /*#__PURE__*/ annotation. It flags a statement as side-effect-free. So a small change makes it possible to tree shake the code:
const Button$1 = /* #__PURE__ */ withAppProvider()(Button);This allows the piece of code to be removed. But there are still questions about the imports, which need to be included and evaluated because they could contain side effects.
To tackle this, we use the "sideEffects" property in package.json.
It's similar to /*#__PURE__*/, but at the module level rather than the statement level. The "sideEffects" property says: "If no direct export from a module flagged as side-effect-free is used, the bundler can skip evaluating the module for side effects."
In Shopify's Polaris example, the original modules look like this:
import './configure';
export * from './types';
export * from './components';For import { Button } from "@shopify/polaris";, this has the following implications:
- include it: include the module, evaluate it, and continue analyzing dependencies
- skip over: don't include it, don't evaluate it, but continue analyzing dependencies
- exclude it: don't include it, don't evaluate it, and don't analyze dependencies
Specifically, per matching resource:
index.js: No direct export is used, but it's flagged withsideEffects-> include it.configure.js: No export is used, but it's flagged withsideEffects-> include it.types/index.js: No export is used, not flagged withsideEffects-> exclude it.components/index.js: No direct export is used and it's not flagged withsideEffects, but re-exported exports are used -> skip over.components/Breadcrumbs.js: No export is used, not flagged withsideEffects-> exclude it. This also excludes all of its dependencies, likecomponents/Breadcrumbs.css, even if they are flagged withsideEffects.components/Button.js: A direct export is used, not flagged withsideEffects-> include it.components/Button.css: No export is used, but it's flagged withsideEffects-> include it.
In this case, only four modules end up in the bundle:
index.js: pretty much emptyconfigure.jscomponents/Button.jscomponents/Button.css
After this optimization, other optimizations can still apply. For example, the buttonFrom and buttonsFrom exports from Button.js are unused too. The usedExports optimization will pick that up, and terser may be able to drop some statements from the module.
Module concatenation also applies, so these four modules plus the entry module (and probably more dependencies) can be concatenated. index.js ends up generating no code.
To better understand the impact of the sideEffects flag, let's look at a complete example of an npm package with CSS assets and how it might be affected during tree shaking. We'll create a fictional UI component library called "awesome-ui".
Our example package looks like this:
awesome-ui/
├── package.json
└── dist/
├── index.js
├── components/
│ ├── index.js
│ ├── Button/
│ │ ├── index.js
│ │ └── Button.css
│ ├── Card/
│ │ ├── index.js
│ │ └── Card.css
│ └── Modal/
│ ├── index.js
│ └── Modal.css
└── theme/
├── index.js
└── defaultTheme.css{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": false
}dist/components/Card/index.js and dist/components/Modal/index.js would have a similar structure.
import './defaultTheme.css'; // This has a side effect!
export const themeColors = {
primary: '#0078d7',
secondary: '#f3f2f1',
danger: '#d13438',
};Now, imagine a consumer application that only wants to use the Button component:
import { Button } from 'awesome-ui';
// Use the Button componentWhen webpack processes this import with tree shaking enabled:
- It sees the import for only
Button. - It looks at
package.jsonand seessideEffects: false. - It determines it only needs to include the Button component code.
- Since all files are marked as having no side effects, it includes only the JavaScript code for the Button.
- The CSS file import gets dropped! Even though
Button.cssis imported inButton/index.js, webpack assumes this import has no side effects.
The result: the Button component renders, but without any styling, because Button.css was eliminated during tree shaking.
To fix this, we need to update package.json to properly mark CSS files as having side effects:
{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": ["**/*.css"]
}With this configuration:
- webpack still identifies that only the Button component is needed.
- But now it recognizes that CSS files have side effects.
- So it includes
Button.csswhen processingButton/index.js.
Here's how webpack evaluates modules during tree shaking:
-
Is the export from this module used, directly or indirectly?
- If yes: include the module.
- If no: continue to step 2.
-
Is the module marked with side effects?
- If yes (
sideEffectsincludes this file or istrue): include the module. - If no (
sideEffectsisfalseor doesn't include this file): exclude the module and its dependencies.
- If yes (
For our library's files with the proper sideEffects configuration:
dist/index.js: No direct export used, no side effects -> skip over.dist/components/index.js: No direct export used, no side effects -> skip over.dist/components/Button/index.js: Direct export used -> include.dist/components/Button/Button.css: No exports, has side effects -> include.dist/components/Card/*: No exports used, no side effects -> exclude.dist/components/Modal/*: No exports used, no side effects -> exclude.dist/theme/*: No exports used, no side effects -> exclude.
The impact of an incorrect side-effects configuration can be significant:
- CSS not being included: components render without styles.
- Global JavaScript not running: polyfills or global configuration don't execute.
- Initialization code skipped: functions that register components or set up event listeners never run.
These issues can be particularly hard to debug because they often appear only in production builds, when tree shaking is enabled.
A good way to test whether your side-effects configuration is correct:
- Create a minimal application that imports just one component.
- Build it with production settings (with tree shaking enabled).
- Check that all necessary styles and behaviors work correctly.
- Look at the generated bundle to confirm the right files are included.
You can tell webpack that a function call is side-effect-free (pure) by using the /*#__PURE__*/ annotation. Place it in front of a function call to mark it as side-effect-free. Arguments passed to the function are not marked by the annotation and may need to be marked individually. When the initial value in a variable declaration of an unused variable is considered side-effect-free (pure), it's marked as dead code, not executed, and dropped by the minimizer. This behavior is enabled when optimization.innerGraph is set to true.
/* #__PURE__ */ double(55);Available in webpack 5.107.0+.
webpack also supports the #__NO_SIDE_EFFECTS__ annotation to mark a function declaration as pure. Calls to a function annotated this way can be eliminated from the bundle when their return value is unused, even if the function body isn't statically analyzable as pure. This is useful for factory or builder functions whose call sites would otherwise need a /*#__PURE__*/ annotation each time.
/*#__NO_SIDE_EFFECTS__*/
export function createLogger(prefix) {
return msg => console.log(`[${prefix}] ${msg}`);
}[!WARNING] The annotation currently only takes effect within the module where it is declared. Cross-module propagation is planned for a future release.
We've cued up our "dead code" to be dropped by using the import and export syntax, but we still need to actually remove it from the bundle. To do that, set the mode configuration option to production.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
- mode: 'development',
- optimization: {
- usedExports: true,
- }
+ mode: 'production',
};[!TIP] Note that the
--optimize-minimizeflag can also be used to enableTerserPlugin.
With that squared away, run npm run build again and see what's changed.
Notice anything different about dist/bundle.js? The whole bundle is now minified and mangled, but if you look carefully, you won't see the square function — only a mangled version of the cube function (function r(e){return e*e*e}n.a=r). With minification and tree shaking, our bundle is now a few bytes smaller. That may not seem like much in this contrived example, but tree shaking can yield a significant decrease in bundle size when working on larger applications with complex dependency trees.
[!TIP]
ModuleConcatenationPluginis required for tree shaking to work. It's added bymode: 'production'. If you're not using that mode, remember to addModuleConcatenationPluginmanually.
When working with tree shaking and the sideEffects flag, there are several common pitfalls to avoid.
Setting sideEffects: false in your package.json is tempting for optimal tree shaking, but it can cause problems if your code actually does have side effects. Examples of hidden side effects:
- CSS imports (as demonstrated above)
- Polyfills that modify global objects
- Libraries that register global event listeners
- Code that modifies prototype chains
Consider this pattern:
// This file has side effects that might be skipped
import './polyfill';
// Re-export components
export * from './components';If a consumer only imports specific components, the polyfill import might be skipped entirely if it isn't properly marked with side effects.
Your package might correctly mark its side effects, but if it depends on third-party packages that mark their side effects incorrectly, you may still run into issues.
Tree shaking typically only fully activates in production mode. Testing only in development can hide tree shaking issues until deployment.
What we've learned is that, to take advantage of tree shaking, you must:
- Use ES2015 module syntax (
importandexport). - Ensure no compiler transforms your ES2015 module syntax into CommonJS modules. (This is the default behavior of the popular Babel preset
@babel/preset-env— see the documentation for details.) - Add a
"sideEffects"property to your project'spackage.jsonfile. - Be careful to correctly mark files with side effects, especially CSS imports.
- Use the
productionmodeconfiguration option to enable various optimizations, including minification and tree shaking. (Side-effects optimization is enabled in development mode using the flag value.) - Set a correct value for
devtool, as some of them can't be used inproductionmode.
You can imagine your application as a tree. The source code and libraries you actually use represent the green, living leaves of the tree. Dead code represents the brown, dead leaves consumed by autumn. To get rid of the dead leaves, you have to shake the tree, causing them to fall.
If you're interested in more ways to optimize your output, head to the next guide for details on building for production.