What Is Tree Shaking? How Bundlers Drop Dead Code
Photo by Lukasz Szmigiel on Unsplash
Table of Contents
The Day I Shipped 400 KB of Lodash by Accident
Years ago I shipped a feature that needed three Lodash functions: `debounce`, `merge`, and `groupBy`. I wrote `import _ from 'lodash'` because that's what the docs showed. Built, deployed, opened the Network tab on a slow connection.
Main bundle: 480 KB gzipped. Of which roughly 400 KB was Lodash. I'd shipped the entire library to use three functions.
I knew about tree shaking. I'd configured webpack for it. The bundler was doing its job — Lodash just wasn't tree-shakeable in its default configuration. Default-importing the namespace pulled everything in. Changing to `import { debounce, merge, groupBy } from 'lodash-es'` cut the bundle by 380 KB.
Tree shaking is one of those features that's invisible when it works and devastating when it doesn't. Most JS apps have at least one library that's silently bloating the bundle because of an import pattern that defeats the optimizer. Let's pull this apart.
What Tree Shaking Actually Does
Photo by Emile Perron on Unsplash
The technique relies on the static structure of ES Modules. `import { x } from 'mod'` and `export const x = 1` are top-level statements that the bundler can analyze at build time without executing any code. The bundler builds a graph of which exports each module provides and which the importing modules actually use. Unreferenced exports get marked dead and dropped.
Why ES Modules specifically? Because `import` and `export` are static — they can only appear at the top of a module, the names are constants, and there's no `if` statement that decides whether to import. CommonJS (`require`/`module.exports`) is dynamic — you can `require` inside a function, assign exports conditionally, or build the export object at runtime. A bundler can't safely drop code from a CommonJS module because it might be needed at runtime in ways the static analysis can't see.
Modern bundlers (Rollup, esbuild, Vite, webpack with the `production` mode, Bun) all tree-shake ES Modules by default. The difference is how aggressive they are and which edge cases they handle. Rollup is generally the most aggressive — it was designed around tree shaking from day one. esbuild is the fastest. webpack is the most configurable. For most apps the differences don't matter, but for library authors the choice of bundler significantly affects how easy your library is to tree-shake.
The Five Mistakes That Silently Defeat Tree Shaking
Mistake 2: Wildcard re-exports in your barrel files. `export * from './a'; export * from './b'` makes the bundler conservative. If even one of the re-exported modules has side effects, the bundler may include everything. Use explicit re-exports: `export { Button } from './button'`.
Mistake 3: Missing `sideEffects: false` in `package.json`. This is the most common one in libraries. Without this field, the bundler assumes importing any module from your package might trigger side effects (mutations, console logs, polyfill installations) and refuses to drop unused exports. Adding `"sideEffects": false` to your `package.json` tells the bundler the modules are pure. If some files do have side effects (like CSS imports or polyfills), you can list them: `"sideEffects": ["./src/polyfills.js", "*.css"]`.
Mistake 4: Side effects at module top level. Code that runs as a side effect of being imported defeats tree shaking. A module that calls `window.addEventListener` or installs a global polyfill at the top level can't be dropped even if nothing imports anything from it. Move side effects into explicit functions that consumers call.
Mistake 5: CommonJS dependencies. If a transitive dependency ships only CommonJS (no ESM build), it can't be tree-shaken. Some packages ship both — check the `main`, `module`, and `exports` fields in their `package.json`. If the `module` field points to a `dist/index.esm.js`, you've got an ESM build available. If only `main` is set, you're stuck with the full CommonJS bundle.
I fix these in roughly that order when auditing an oversized bundle. The first two account for maybe 70% of the wins. The other three matter for fine-tuning. If you're new to JS modules, the What Is Base64 article is an unrelated but useful read on how strings and bytes flow through JavaScript — the encoding mindset translates.
How to Verify Your Bundle Is Actually Tree-Shaking
Photo by Compare Fibre on Unsplash
webpack-bundle-analyzer. Run `npm install --save-dev webpack-bundle-analyzer`, add the plugin, build, and you get an interactive treemap of every module in your bundle. If you see Lodash taking up 400 KB when you only used `debounce`, you've found a problem. This tool has saved me more bundle weight than any other.
source-map-explorer. Similar to the bundle analyzer but works against the deployed bundle and source map. Run `npx source-map-explorer dist/main.js`. Same treemap, but it doesn't require a webpack plugin and works with any bundler that emits source maps.
Lighthouse → Unused JavaScript audit. Chrome DevTools has built-in detection for JS that's loaded but never executed. Open Lighthouse, run a perf audit, look for "Reduce unused JavaScript." It'll list the worst offenders by KB.
`import-cost` VS Code extension. Adds an inline annotation to every import statement showing the bundle cost. Watching the number tick up as you type forces you to question whether you really need that library. It's the most behavior-changing tool on the list.
I run the bundle analyzer at least once a sprint. Things drift. New imports slip in. A library you weren't using gets pulled in by a transitive dependency. Without a regular check, your bundle slowly bloats by hundreds of KB without anyone noticing.
If you want the canonical source on this, the webpack tree shaking guide is well-written and worth a read even if you use a different bundler. The concepts are universal.
When Tree Shaking Isn't Enough
For library code that's bigger than the imports it provides, you need code splitting. `import()` with a dynamic import statement creates a separate chunk that's only loaded when needed. Use it for routes (Next.js does this by default), heavy components (charts, rich-text editors), and conditional features.
For polyfills and large compatibility shims, use differential serving — modern browsers get a tiny ES2022 bundle, older browsers get the transpiled+polyfilled version. Tools like Vite handle this automatically with the `legacy` plugin.
For things tree shaking can't touch — large strings, JSON data, icon sprites — manual extraction wins. Move them to separate files loaded on demand. I once cut a bundle by 200 KB by moving a translation JSON to a separate fetch instead of inlining it.
And remember: the cheapest byte is the one you don't ship. Before optimizing imports, ask whether you need the library at all. Date-fns replaced Moment.js. Native `URL` and `URLSearchParams` replaced query-string. `fetch` replaced Axios for most use cases. The browser's native APIs in 2026 are powerful enough that many old library defaults are unnecessary.
If you're tightening your build pipeline, the npm vs Yarn vs pnpm comparison covers how package managers affect duplicate dependencies — which is its own source of bundle bloat. And my debouncing vs throttling article shows how to write tiny replacements for the most common Lodash imports.
One pattern I've come around on this year: instead of fighting Lodash's tree-shaking story, I just stopped reaching for Lodash. For the dozen or so methods I used to import — `debounce`, `throttle`, `groupBy`, `keyBy`, `pick`, `omit`, `cloneDeep`, `isEqual`, `merge` — there's now a native or near-native answer for almost all of them. `structuredClone()` replaces `cloneDeep`. `Object.fromEntries` plus `map` covers `pick` and `omit`. `Array.prototype.reduce` does `groupBy` in three lines. The few cases that genuinely need a library (deep equality, deep merge with custom resolvers) are small enough to inline as a 15-line utility in your codebase. Total dependency cost: zero. Total bundle cost: a couple of KB.
That's not a tree-shaking solution — it's a step before tree-shaking. But the cheapest byte is still the one you don't import.
A final note on something I get asked a lot: does tree shaking matter as much in 2026 as it did five years ago? My answer is yes, maybe more. Network speeds have improved, sure, but JS execution time hasn't kept pace — every kilobyte of script still has to be parsed, compiled, and executed on the main thread. On a mid-range Android phone, that's still a real cost. The Core Web Vitals INP metric punishes heavy main-thread work directly. So even if your users have fast pipes, the script you ship still costs them milliseconds of unresponsiveness on every interaction. Tree shaking is one of the cheapest wins available — you don't write code, you just configure a build flag and import patterns correctly. The ROI is roughly infinite.
Frequently Asked Questions
Does tree shaking work with CommonJS modules?
Mostly no. CommonJS exports are dynamic — `module.exports` is just an object you mutate at runtime — so bundlers can't statically determine which exports are used. Modern bundlers can sometimes handle simple CommonJS cases (a top-level `module.exports = { foo, bar }` looks ESM-like enough), but it's brittle. For reliable tree shaking, use ES Modules end-to-end. If a critical dependency only ships CommonJS, consider switching to an ESM alternative or wait for an ESM release.
What does `sideEffects: false` in package.json do?
It tells the bundler that no module in your package has side effects at import time, so unused exports are safe to drop. Without it, bundlers conservatively assume importing any file might do something (set a global, install a polyfill, run init code) and keep all the code. If your package does have side-effecting files (CSS imports, polyfills, registration code), list them: `"sideEffects": ["./src/polyfills.js", "*.css"]`. For dev tool background, check the [tools page](/tools) for ToolsFuel's free utilities or browse the [blog index](/blog) for more JS guides.
How do I tell which import pattern tree-shakes properly?
Install `webpack-bundle-analyzer` (or `source-map-explorer` for non-webpack builds), do a production build, and look at the treemap. If a library you use only one function from takes up 50+ KB, your import pattern isn't tree-shaking. Switch to named imports from the ESM build (often called `library-es`, `library/esm`, or available via the `exports` field) and re-measure. Also use the `import-cost` VS Code extension to catch problems before they ship.
Why is my Lodash import still 400 KB after tree shaking?
Lodash's default `main` field points to a CommonJS build that can't be tree-shaken. You have three options: import named methods from `lodash-es` (`import { debounce } from 'lodash-es'`), import individual methods from sub-paths (`import debounce from 'lodash/debounce'`), or replace Lodash entirely with native alternatives or a smaller library like `radash`. The first option is usually the simplest drop-in fix.
Does tree shaking work in development builds?
Usually no, and that's intentional. Tree shaking adds build time and obscures the source-to-bundle mapping, which makes debugging harder. Most bundlers only tree-shake in production mode. webpack tree-shakes when `mode: 'production'` is set. Vite tree-shakes during `vite build` but not during `vite dev`. Don't measure bundle size in dev — always run a production build first.
Is tree shaking the same as dead code elimination?
Not exactly. Dead code elimination is a broader compiler concept that removes any unreachable code (an `if (false) { ... }` block, an unused local variable). Tree shaking is a specific form of dead code elimination that operates at the module-export level — it removes exports that nothing imports. A bundler typically does both: tree shaking at the import graph, then minifier-level dead code elimination inside the surviving code. Both together usually shrink output by 40-60%.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools