Tree shaking: why your bundle still ships unused code
Tree shaking removes code you never import, but only when the bundler can prove it is unused. ES modules, the sideEffects flag and your import style decide what goes.
Tree shaking is the bundler's way of leaving out code you never use. Webpack, Rollup, Vite and esbuild all do it. Yet many projects that have it switched on still ship whole libraries for the sake of one function. The reason is that tree shaking only works when the bundler can prove the code is unused.
It depends on ES modules
import and export are static. The bundler can read a file and know exactly what it uses without running it. CommonJS is different: require() is a function call that can happen anywhere, with any argument, so the bundler has to keep everything.
Two things follow from that:
- A library published only as CommonJS will not shake well.
- Your own build must not convert modules to CommonJS before bundling. With Babel, keep
modules: falsein@babel/preset-env. With TypeScript, use"module": "esnext".
The Lodash example, done properly
This is the case most tutorials get wrong.
// Ships the whole library
import _ from "lodash";
// Looks selective, but lodash is CommonJS, so most setups still ship all of it
import { debounce } from "lodash";
// Ships only debounce
import debounce from "lodash/debounce";
// Also fine: the ES module build of the same library
import { debounce } from "lodash-es";
Named imports only help when the package is written as ES modules.
Tell the bundler what is safe to drop
A module can do work just by being imported, such as registering a polyfill or injecting CSS. The bundler cannot always tell, so it plays safe and keeps the file. The sideEffects field in package.json removes the doubt.
{
"sideEffects": ["*.css"]
}
This says: everything except CSS files can be dropped when nothing is imported from it. Use false if no file has side effects at all.
What quietly blocks tree shaking
- Code that runs at the top of a module. If importing a file changes something global, the file stays.
- Large classes. A bundler removes unused exports, not unused methods. One class with forty methods ships all forty.
- Dynamic access.
utils[name]()forces the bundler to keep every member ofutils. - Wildcard re-exports of CommonJS code. One non-shakeable file can hold a whole barrel file in the bundle.
Check that it worked
Open the bundle in an analyzer, or simply search the production output for the name of a function you know you never call. If it is there, something above is keeping it alive.
Tree shaking is one part of a smaller bundle. The rest is covered in how to reduce JavaScript bundle size.