How Tree-Shaking Works
Tree-shaking is the elimination of unreachable code — "dead code" — from a JavaScript bundle. The metaphor is simple: your application is a tree, the source code and libraries you actually use are the living leaves, and dead code represents dead leaves that need to be shaken off. The term was popularized by the Rollup team, though the underlying algorithm dates back to the early 1990s.
Tree-shaking became practical in JavaScript with the arrival of ECMAScript modules (ESM) in ES2015. Most bundlers now enable it by default because it reduces output size without altering program behavior. The key reason is that ESM is static by nature.
Static vs. Dynamic Modules
CommonJS predates ESM and addresses modularity with a require() function — but that function makes evaluation hard at compile time. require calls can appear anywhere: wrapped in function calls, inside conditionals, within switch statements. This dynamism means the outcome of a require call cannot be reliably determined during compilation.
ESM solves this by providing dedicated keywords, import and export, that function as top-level declarations only. They cannot be nested in other structures. This constraint makes ESM static: it does not depend on runtime execution, so the bundler can inspect and analyze it entirely at compile time.
Side Effects and Purity
Tree-shaking hits a wall with side effects. A function with side effects alters or depends on something outside its scope — such a function is impure. A pure function always yields the same result regardless of where it runs.
const pure = (a:number, b:number) => a + b
const impure = (c:number) => window.foo.number + c
Bundlers evaluate code as much as they can to determine if a module is pure, but compile-time inspection has limits. Packages suspected of side effects cannot be safely eliminated, even when nothing imports them. To solve this, bundlers accept a sideEffects key in package.json. Declaring it lets the developer signal whether a module has side effects — if there is no reachable import or require referencing that code, it can be dropped. This both trims the bundle and speeds compilation. If you publish packages, review your sideEffects declaration on each release to avoid unexpected breakage.
{
"name": "my-package",
"sideEffects": false
}
There is also a file-level option: the inline comment /*@__PURE__*/ marks a method call as pure when the package itself has not declared sideEffects: false.
const x = */@__PURE__*/eliminated_if_not_called()
Treat the inline annotation as an escape hatch for consumers dealing with libraries that did not signal purity.
Webpack Configuration
Since version 4, Webpack has folded much of its best-practice configuration into core. If your application has no special cases, tree-shaking reduces to one property: mode. Setting it to production activates optimization, eliminating dead code with the TerserPlugin, deterministic mangling, and enabling:
- flag dependency usage,
- flag included chunks,
- module concatenation,
- no emit on errors.
The production value is deliberate — full optimization makes debugging harder in development. Two approaches are common. Pass a mode flag on the command line:
# This will override the setting in your webpack.config.js
webpack --mode=production
Or check the process.env.NODE_ENV variable inside webpack.config.js:
mode: process.env.NODE_ENV === 'production' ? 'production' : development
In that case, set --NODE_ENV=production in your deployment pipeline. Both approaches are abstractions over the older definePlugin from Webpack 3 and below, so the choice is purely a preference.
Older Webpack and Evaluation Limits
Webpack versions 3 and below do not support the sideEffects property, so every package must be fully inspected before code can be dropped. Several fuzzy cases prevent elimination entirely. Consider this package from Webpack's documentation:
// transform.js
import * as mylib from 'mylib';
export const someVar = mylib.transform({
// ...
});
export const someOtherVar = mylib.transform({
// ...
});
Now the consumer entry point:
// index.js
import { someVar } from './transforms.js';
// Use `someVar`...
The compiler cannot determine whether mylib.transform triggers side effects, so nothing gets removed. Similar situations arise when:
- invoking a function from a third-party module the compiler cannot inspect,
- re-exporting functions imported from third-party modules.
One workaround is babel-plugin-transform-imports, which splits member and named exports into default exports so each module can be evaluated individually.
// before transformation
import { Row, Grid as MyGrid } from 'react-bootstrap';
import { merge } from 'lodash';
// after transformation
import Row from 'react-bootstrap/lib/Row';
import MyGrid from 'react-bootstrap/lib/Grid';
import merge from 'lodash/merge';
It also offers a configuration utility that warns against troublesome import statements. If you are on Webpack 3 or above, have done the basic configuration, and the bundle still feels heavy, this plugin is worth trying.
Scope Hoisting and Compilation Cost
With CommonJS, bundlers wrapped each module in a function and stored them in a map object:
(function (modulesMap, entry) {
// provided CommonJS runtime
})({
"index.js": function (require, module, exports) {
let { foo } = require('./foo.js')
foo.doStuff()
},
"foo.js": function(require, module, exports) {
module.exports.foo = {
doStuff: () => { console.log('I am foo') }
}
}
}, "index.js")
That format is hard to analyze statically and fundamentally incompatible with ESM, since import and export cannot be wrapped. Modern bundlers therefore hoist every module to the top level:
// moduleA.js
let $moduleA$export$doStuff = () => ({
doStuff: () => {}
})
// index.js
$moduleA$export$doStuff()
This is fully ESM-compatible, and makes it easier for the evaluator to spot modules that are never called and drop them. The trade-off is compilation time: scope hoisting touches every statement and keeps the bundle in memory. That cost helps explain the push toward compiled tooling, such as esbuild, written in Go, and SWC, a TypeScript compiler in Rust integrated with the Rust-based Spark bundler. For a deeper look, Parcel version 2's scope hoisting documentation is a good resource.
The Transpilation Trap
A common and damaging problem arises when loaders run compilers before optimization. TypeScript, Babel, and Webpack form typical combinations — and the compilers often output CommonJS modules by default or through misconfiguration. CommonJS is dynamic, so dead-code elimination cannot reliably happen after transformation.
The situation is increasingly frequent with isomorphic applications that share code between server and client. Node.js has historically lacked standard ESM support, so compilers targeting the node environment emit CommonJS. Always check what your optimizer actually receives before blaming the tooling.
Tree-Shaking Checklist
- Use ESMs — not just in your own code, but choose packages that ship ESM output.
- Know which dependencies have not declared
sideEffects, or have it set totrue. - Annotate pure method calls with inline declarations when consuming packages that have side effects.
- If you output CommonJS, run optimization before transforming import and export statements.
Building Packages That Play Well With Bundlers
While the ecosystem's push toward ES modules is clear, adoption is rarely instant. Package authors can ease the transition without breaking existing consumers by adding a few fields to package.json, letting bundlers know which environments are supported and how to handle the code. Skypack maintains a concise checklist for this:
- Ship an ESM export.
- Set
"type": "module". - Point bundlers to the ESM build using the
"module": "./path/entry.js"field, a widely accepted community standard.
Here is an example of a package.json that follows best practices while supporting both browser and Node.js runtimes:
{
// ...
"main": "./index-cjs.js",
"module": "./index-esm.js",
"exports": {
"require": "./index-cjs.js",
"import": "./index-esm.js"
}
// ...
}
To help developers verify their packages meet these standards, Skypack also maintains an open-source package quality score tool. The package-check utility, available on GitHub, can be installed as a devDependency so checks can run automatically before each release.
Further Reading
For deeper dives into module resolution and bundler internals, these resources are worth exploring:
Editorial and Documentation
- "ES Modules: A Cartoon Deep-Dive" by Lin Clark on Mozilla Hacks.
- Tree Shaking and Configuration guides from Webpack.
- Webpack's Optimization documentation.
- Parcel 2's Scope Hoisting feature docs.
Tools and Projects
- Terser for minification.
- babel-plugin-transform-imports for selective imports.
- Bundlers: Webpack, Parcel, Rollup, esbuild, and SWC.
- Skypack and its Package Check tool.
Related analysis on performance and JavaScript from Tech Report's archives covers topics from module bundling to Core Web Vitals reporting.




