All topics
Performanceadvanced

Bundle Size Optimization and Tree Shaking

Techniques bundlers use to eliminate unused code from a final JavaScript bundle, relying heavily on ES modules' static import/export structure.

Tree shaking is the process by which a module bundler statically analyzes an application's import/export graph and eliminates code that's provably never actually used, producing a smaller final bundle than simply concatenating every imported file's entire contents together regardless of what's actually referenced. It's one of the most impactful build-time optimizations for real-world bundle size, and it relies specifically on a structural property of ES modules that older module systems didn't reliably provide.

Tree shaking is like a moving company that, instead of packing an entire warehouse's full inventory onto the truck because you rented space there, only loads the specific labeled boxes you've actually indicated you need at your new place, based on a complete, itemized list you provided upfront (the static import graph) — nothing gets shipped that wasn't explicitly itemized as needed.

Key Concepts

1
Tree shaking depends on ES modules' *static* import/export structure — because import/export statements must appear at a module's top level (not conditionally, inside an if or a function), a bundler can determine, purely by statically analyzing the code without running it, exactly which specific named exports from a given module are actually imported and used anywhere in the application, and safely omit every other unused export from the final bundle. This static analyzability is precisely what CommonJS's require()/module.exports — being ordinary function calls that could occur conditionally, anywhere, at runtime — doesn't reliably provide, which is why CommonJS modules generally tree-shake far worse (often not at all) compared to genuine ES modules.
importexportifrequire()module.exports
2
A library structured with many small, individually-exported functions in separate files tree-shakes well, since importing just one specific function only pulls in that function's own code; a library that exports one giant default object bundling every function together as properties, by contrast, tree-shakes poorly, since the bundler generally can't prove which of that object's properties are 'actually used' with the same static confidence, and often ends up including the whole object. This is exactly why many utility libraries restructured their exports over time (individual named exports per function, rather than one giant default-exported object) specifically to become tree-shakeable, and why import statement style (import { debounce } from 'lodash-es' versus import _ from 'lodash') can have a dramatically different impact on final bundle size for the exact same underlying functionality.
import { debounce } from 'lodash-es'import _ from 'lodash'
3
Side effects are the other major factor bundlers must account for: if a module's top-level code does something observable beyond just defining exports (registering a global polyfill, attaching an event listener at import time), the bundler generally can't safely remove it even if none of that module's actual exports are used, since doing so could change the application's behavior — this is exactly what the "sideEffects": false field in a package's package.json communicates to bundlers, explicitly asserting 'it's safe to tree-shake unused exports away entirely, importing this module purely for its side effects should never be assumed.'
"sideEffects": falsepackage.json