BundlingGENERALFRAMEWORK-SPECIFICSIMPLIFIED

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.

The question

I import one function from a large library. Why did the whole library end up in my bundle?

The user intent

A person downloads an application. They should not be paying for the four hundred exported functions nobody in this codebase ever calls.

The obvious build

Modern bundlers tree-shake, so unused code is removed automatically. Import whatever is convenient and the build will sort it out.

Why it breaks

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.

How it breaks in a real browser
  • 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. require is 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": false when 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.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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": false in 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).
Two imports, one graph edge each
Through the barrel
// 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'
Direct
// 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.

Why the code is still there
TriggerSymptomCauseResponse
Module top level does something observableA module you never call is in the outputRemoving it would remove the effect, so removal is not legalMove 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 themA stylesheet or registration is missing in production onlyThe bundler believed a false promise and dropped an effectful importReplace false with an array naming the effectful files. Never set false to make a size number look better.
Dependency published as CommonJSA whole library ships for one imported functionrequire 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-exportUnrelated components appear in the treemapOne import made the whole folder reachableImport the module directly; keep barrels for genuinely pure packages.
Dynamic access to exportsNothing is removed from a namespace importlib[key] could be any export, so all of them must be keptUse named imports, or an explicit map from key to imported binding.
Class with static initialisation or a decoratorA class hierarchy survives despite never being constructedStatic initialisers and decorators run at definition time and count as effectsMove 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.

A package manifest that tells the truth
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 exports map. This is a real selection criterion when two libraries do the same job.
  • Declare "sideEffects" accurately in your own packages — false when true, or an array listing the CSS and polyfill entry points that do have effects. An inaccurate false is 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 lib makes 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-visible styling 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

Failure modes
  • 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": false that 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).
Security
  • 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).
Misreads
  • "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: false makes 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

How you would see this
  • 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).
Slow device, slow network, large data, old tab
  • 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, exports map and sideEffects declaration are the whole story (Bundlers Compared).
  • On a slow device, every surviving unused byte costs parse and compile time even if it never executes.
What this costs
  • Deep imports are more verbose and churn more when files move, in exchange for a graph that reflects the dependency.
  • An accurate sideEffects declaration 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.

Domains that do not exist yet
  • 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.