Tree Shaking
Static module analysis can allow unused exports to be dropped — and five specific things routinely stop it: side effects, a wrong sideEffects flag, CommonJS interop, barrel re-exports and dynamic access.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
I import one function from a large library. Why did the whole library end up in my bundle?
A person downloads an application. They should not be paying for the four hundred exported functions nobody in this codebase ever calls.
Modern bundlers tree-shake, so unused code is removed automatically. Import whatever is convenient and the build will sort it out.
Tree shaking is not garbage collection. It is a static proof that removing something cannot change behaviour, and when the proof fails the code stays — silently, with no warning.
- Tree shaking is not garbage collection. It is a static proof that removing something cannot change behaviour, and when the proof fails the code stays — silently, with no warning.
- A module whose top level does anything observable cannot be dropped even if none of its exports are used, because dropping it would remove that effect.
- Importing a named export from a barrel that re-exports fifty modules makes all fifty reachable. Whether the tool can then prove forty-nine are unused depends on every one of them being side-effect free.
- A library published as CommonJS has no static export structure to analyse.
requireis a function call whose result can be anything, so the safe answer is to keep the whole module (ESM vs CommonJS). - Accessing an export dynamically —
lib[name]— defeats the analysis entirely, because the tool cannot know which name will be used. - Marking a package
"sideEffects": falsewhen it is not true does not produce an error. It produces a build where a stylesheet import or a polyfill registration has vanished, and the failure appears somewhere unrelated.
What is actually happening
In the browser, not in the framework.
- ESM imports and exports are static: the specifier is a literal, the bindings are named, and the structure is known before execution. That is the property the whole analysis rests on (The Module Graph).
- The bundler marks every export reachable from an entry as used, then removes the unused ones, then removes any code that only the removed exports needed. It is a reachability analysis over bindings rather than over files.
- Removal is only legal if the removed code has no observable effect. Assigning to a global, mutating a prototype, registering a custom element, importing a stylesheet, or calling anything the tool cannot see inside are all effects.
- Because proving purity is undecidable in general, tools rely on conservative heuristics plus annotations. A
/* @__PURE__ */marker on a call expression tells the minifier that the call may be dropped if its result is unused. "sideEffects": falsein a package's manifest is a promise to the bundler that importing any module in that package for its bindings alone is safe. It can also be an array of the files that *do* have effects.- The final removal usually happens in the minifier, not in the graph pass: the bundler marks bindings unused and the minifier deletes the now-unreachable code (Minification Is Not Compression).
What this makes the browser do
And which of it is avoidable.
- None, directly — this is entirely a build-time analysis. What the browser sees is the consequence: fewer bytes to download, parse, compile and execute (The Real Cost of JavaScript).
- Code that survives tree shaking but is never called still costs parse and compile time on arrival, which is why "it is unused, so it is free" is wrong even when the function never runs.
- Module-level side effects in surviving modules run at import time, on the main thread, before anything they were imported for is used.
The barrel, and what it costs
The most common way a project defeats its own tree shaking is a convenience re-export. A single index.ts that re-exports every component in a folder makes one import an edge to all of them, and now removal depends on every module in that folder being provably effect-free.
Often they are, and the analysis succeeds — which is exactly why the pattern survives. It fails the moment one module in the barrel imports a stylesheet, registers something, or comes from a CommonJS dependency, and then the failure is attributed to "the bundler" rather than to the barrel.
- Barrels are not always wrong. In a package whose modules are genuinely pure,
export *is fine and the analysis handles it. - The risk scales with the folder. A barrel over four small components is low risk; a barrel over the whole design system is where this bites.
- A tool's "why is this included" output names the barrel explicitly, which is the fastest way to confirm the diagnosis (Bundle Analysis).
// components/index.ts
export * from './button'
export * from './chart' // pulls in a charting library
export * from './editor' // registers a custom element on import
export * from './date-field' // imports a locale bundle
// consumer
import { Button } from '@/components'// consumer
import { Button } from '@/components/button'
// The graph now has one edge. Nothing else in the folder is
// reachable, so nothing else needs to be proven pure.The direct import makes the module unreachable rather than reachable-but-hopefully-removable. Reachability is a fact the tool computes; purity is a proof it may fail to construct — and when the proof fails there is no error, only a larger bundle.
The five things that defeat it
Almost every disappointment with tree shaking is one of these five. Working through them in order is faster than any amount of configuration.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Module top level does something observable | A module you never call is in the output | Removing it would remove the effect, so removal is not legal | Move the effect behind an exported function the caller invokes, or declare the file in sideEffects and accept that it ships. |
"sideEffects": false on a package that has them | A stylesheet or registration is missing in production only | The bundler believed a false promise and dropped an effectful import | Replace false with an array naming the effectful files. Never set false to make a size number look better. |
| Dependency published as CommonJS | A whole library ships for one imported function | require is a runtime call with no analysable export structure (ESM vs CommonJS) | Prefer a dependency with an ESM build, use a deep import into a submodule, or replace the dependency. |
| Barrel re-export | Unrelated components appear in the treemap | One import made the whole folder reachable | Import the module directly; keep barrels for genuinely pure packages. |
| Dynamic access to exports | Nothing is removed from a namespace import | lib[key] could be any export, so all of them must be kept | Use named imports, or an explicit map from key to imported binding. |
| Class with static initialisation or a decorator | A class hierarchy survives despite never being constructed | Static initialisers and decorators run at definition time and count as effects | Move initialisation into a factory, or accept it and split the module instead (Code Splitting). |
Declaring purity you actually have
The sideEffects field is a promise to every bundler that consumes your package. Getting it right is what allows a consumer to import one component from you without taking the rest; getting it wrong produces a build that is missing something, in production, with no error at build time.
The array form is almost always the honest answer. Stylesheets have effects. Polyfills have effects. Anything that registers a custom element or patches a global has effects. Everything else usually does not.
1{2 "name": "@acme/ui",3 "type": "module",4 "exports": {5 ".": "./dist/index.js",6 "./button": "./dist/button.js",7 "./chart": "./dist/chart.js",8 "./styles.css": "./dist/styles.css"9 },10 "sideEffects": [11 "**/*.css",12 "./dist/register-elements.js"13 ]14}The exports map is what makes deep imports possible for consumers at all — without it, @acme/ui/button is not a resolvable specifier. sideEffects as an array is the difference between "trust me" and "here is exactly what to keep".
How to build it
Most important first.
- Import from the module you need rather than through a barrel. Deep imports make the graph reflect the dependency and remove the analysis from the critical path (The Module Graph).
- Prefer dependencies that publish an ESM build with an accurate
exportsmap. This is a real selection criterion when two libraries do the same job. - Declare
"sideEffects"accurately in your own packages —falsewhen true, or an array listing the CSS and polyfill entry points that do have effects. An inaccuratefalseis worse than omitting it. - Keep module top levels boring. Export functions and constants; do not register, patch or configure anything at import time.
- Verify rather than assume: build, open the treemap, and check that the library you imported one function from is not present in full (Bundle Analysis).
- Where a library exposes both a namespace import and named exports, use the named ones.
import * as libmakes every property potentially reachable.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Shipping less JavaScript is an accessibility improvement in its own right: on a low-end device the wait between visible pixels and an operable interface is main-thread work, and assistive technology is behind the same thread (Long Tasks).
- Over-aggressive removal can delete accessibility behaviour. A focus-management polyfill, a live-region utility registered at import time, or an accessible component registered as a custom element are all effects a wrong purity claim will silently drop.
- The same risk applies to CSS: a purge pass that keeps only classes it can see in markup will remove
:focus-visiblestyling and state classes applied at runtime, leaving keyboard users with no visible focus (Keyboard Operability). - Test the production build with a keyboard and a screen reader. Removal passes only run there, so a development build proves nothing about what survived (Accessibility Testing).
What can go wrong
- A barrel file that turns one import into a graph edge to the entire folder, defeating removal for anything in that folder with an effect.
- A CommonJS dependency that cannot be analysed at all, so the whole module ships regardless of what you imported (ESM vs CommonJS).
- A
"sideEffects": falsethat is wrong, producing a build missing a stylesheet or a registration — and only in production, because the dev server did not run the removal pass. - A class with static initialisation or decorators that the tool must treat as effectful, keeping the whole class hierarchy.
- Dynamic property access on a namespace import, which forces the tool to retain every export.
- A polyfill imported for its effect being removed because someone marked the package pure. The symptom appears in an old browser, not in CI (Polyfills vs Transpilation).
- Tree shaking is not a security boundary. Code removed from the bundle is not code you have removed from your dependency tree — the package is still installed and still runs its install scripts (Dependency Security in Security).
- Removing unused exports slightly narrows what an attacker can reach through a supply-chain compromise, but a malicious module puts its payload in the top-level side effect precisely because that cannot be shaken out (Software Supply Chain Security in Security).
- A dead-code branch guarded by an inlined environment variable is removed at build time, which is how a development-only debug endpoint stops shipping. If the flag is not inlined, the branch stays and is readable (Feature Flags in the Client).
- Nothing removed here was ever secret. Anything that survives is public, and anything that was removed was public in every previous build (Storage Security and Durability).
- "Modern bundlers tree-shake, so unused code is removed." They can remove code they can prove is unused and effect-free. Both halves fail routinely.
- "Importing one function from a package only costs that function." Only if the package is ESM, side-effect free and does not funnel everything through a barrel.
- "
sideEffects: falsemakes the package smaller." It permits removal. It also silently discards real effects if the claim is false. - "Unused code is free because it never runs." It is downloaded, parsed and compiled on arrival. Execution is only the last of four costs.
- "Dead code elimination and tree shaking are the same thing." Dead code elimination removes unreachable code within a module; tree shaking removes unused *exports* across the module graph. The minifier does the first, the graph pass enables the second.
Measuring it, and what changes in the field
- A treemap of the output, which shows immediately whether a library arrived whole or in part (Bundle Analysis).
- The bundler's "why is this included" query, which names the import chain that made a module reachable.
- A minimal reproduction: a scratch entry that imports exactly one symbol from the library, built with the same config. If the output is large there, the library is the problem, not your application.
- Size tracking in CI so a dependency upgrade that drops its ESM build shows up as a size regression rather than as a mystery (Regression or Tuesday? Telling a Real Change from Noise in Observability & Performance).
- On a large application with many dependencies, barrels and CommonJS interop compound: a handful of unanalysable packages can account for most of the surviving weight.
- In development, removal passes are usually disabled entirely, so the dev bundle tells you nothing about what would be shaken.
- For a library you publish, the analysis happens in your *consumers*' builds. Your output format,
exportsmap andsideEffectsdeclaration are the whole story (Bundlers Compared). - On a slow device, every surviving unused byte costs parse and compile time even if it never executes.
- Deep imports are more verbose and churn more when files move, in exchange for a graph that reflects the dependency.
- An accurate
sideEffectsdeclaration is maintenance: every new effectful file must be added to the array, and forgetting is a silent production bug. - Choosing dependencies by their build format sometimes means choosing the less pleasant API, or vendoring a small piece rather than taking a large package.
- Verifying the output costs a step in the build and a habit nobody enjoys. It is also the only thing that turns tree shaking from a hope into a fact.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe requirement — static module structure plus provable absence of side effects — follows from ESM semantics and applies to every tool. What differs is how conservative each tool's purity heuristics are, so the same source can shake differently under two bundlers.
- FRAMEWORK-SPECIFICCompiler-based frameworks remove component code the template never references, which is a different analysis operating on a different unit than export-level tree shaking; a component tree can shrink there in ways a plain ESM analysis cannot achieve (The Svelte Mental Model).
- SIMPLIFIEDDescribing this as one pass understates it: reachability marking, purity annotation, scope hoisting and dead-code elimination in the minifier all contribute, and tools split the work between them differently. The five defeaters listed here apply regardless of the split.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Compilers & Programming Languages — reachability analysis, purity inference and dead-code elimination as classical compiler passes, and why proving a call has no observable effect is undecidable in general.