Keeping Tree-Shaking Working with PURE Annotations
A bundler drops an unused constant, but it keeps an unused const x = createThing() — because it cannot prove createThing() has no side effect, so removing the call might change behaviour. The /*#__PURE__*/ annotation is how you promise a specific call is side-effect-free, letting the bundler eliminate it when its result is unused. This guide uses it deliberately. It extends tree-shaking mechanics and dead code elimination; the parent overview is Core Concepts of Modern Bundling.
The reason this problem exists at all is that JavaScript has no way to declare, in the language itself, that a function has no observable side effect. A bundler performing dead-code elimination walks the module graph, marks which bindings are reachable from an entry point, and deletes the rest. Deleting an unreferenced const is trivially safe because a binding with no reads and no initializer side effect cannot influence the program. But the moment the initializer is a call expression, the analysis stalls: the bundler would have to prove that evaluating createThing() does nothing observable — no network request, no document mutation, no global registry write, no console.log. That proof is undecidable in the general case, and even the tractable cases require whole-program flow analysis that bundlers deliberately do not perform because it is slow and fragile. So the conservative default is to assume every call might matter and keep it. That default is correct for safety and terrible for bundle size, because factory-heavy libraries litter your modules with top-level calls whose results you never touch.
/*#__PURE__*/ is the escape hatch that shifts the burden of proof from the bundler to you. It is a magic comment — not part of ECMAScript, but a convention honored by Rollup, esbuild, Terser, SWC, and Webpack’s minimizer — that annotates a single call or new expression as free of side effects. When the bundler sees the annotation and finds the call’s result unreferenced, it treats the whole expression as dead and removes it. This matters most at the boundary between libraries and applications: a UI or state library exports a factory like createIcon() or styled.div\…``, your bundle imports fifty of them, uses three, and the other forty-seven should vanish. Without annotations they survive, drag their transitive dependencies along, and inflate the shipped bytes. The rest of this guide is a reproducible demonstration of the failure, the exact fix, and how to verify the call is actually gone rather than assuming it.
Prerequisites & reproducible setup
# Rollup 4 / Vite 5.4.x, Node 20+
mkdir pure-demo && cd pure-demo && npm init -y
npm install --save-dev rollup @rollup/plugin-node-resolve
mkdir -p src
// src/lib.js
export function createWidget(name) { return { name, id: Math.random() }; }
// src/index.js
import { createWidget } from './lib.js';
const unused = createWidget('a'); // result never used
export const used = 42;
Build this and createWidget('a') survives in the output even though unused is never referenced — the bundler cannot prove the call is safe to remove. The Math.random() inside the factory is a red herring: it is deterministic-free and harmless, but the bundler never looks inside createWidget to decide, because cross-function purity inference is exactly the analysis it declines to run. It sees a call at the top level, and a call is opaque. Note the file writes go into src/, so run mkdir -p src before the heredocs if npm init did not create it; keeping the two source files tiny is deliberate so the grep in the verification step has a single unambiguous target.
This setup is the minimal shape that reproduces the problem in production code. Swap createWidget for any real factory — createSlice from Redux Toolkit, cva() from class-variance-authority, an icon component wrapped by a design-system codegen step — and the mechanics are identical: a top-level call whose result is conditionally unused, retained because the bundler is conservative. The value of reproducing it in three lines is that you can toggle exactly one variable, the annotation, and watch the output change, which is impossible to do cleanly inside a real dependency graph.
How it works under the hood
The annotation is consumed during parsing, not as an afterthought. When Rollup’s or esbuild’s parser tokenizes the source, it attaches leading comments to the AST node that follows them. A CallExpression or NewExpression node that carries a comment whose text matches the pattern #__PURE__# or @__PURE__@ gets a hasPureAnnotation flag set on it. There is deliberate flexibility in the delimiter — both /*#__PURE__*/ and /*@__PURE__*/ are accepted, a legacy of Uglify and Closure Compiler using @ while the newer convention prefers # — so if you inherit a codebase mixing both, they behave identically.
During the tree-shaking pass the bundler builds a dependency graph where each top-level statement is a node and edges represent “this statement is needed because that binding is referenced.” A statement is retained if it is reachable from an entry export or if it has an unconditional side effect. The purity flag is what lets the analyzer classify an assignment like const unused = createWidget('a') as having no side effect: the binding unused has no readers, and the initializer — normally treated as a possible side effect — is now marked pure, so the whole statement becomes unreachable and is pruned. Without the flag the initializer is an assumed side effect and the statement is unconditionally retained regardless of whether unused is read.
Crucially, the annotation is transitive through the call’s own dependencies but not through its arguments. If a pure call references imported helpers, dropping the call also drops the now-unreferenced imports, which is where the real byte savings compound: eliminating one factory call can strip its entire imported subtree. But the arguments are evaluated positionally and are not covered by the promise — /*#__PURE__*/ f(g()) still retains g() because g() is a separate call expression with its own purity status. The annotation says “the act of calling f with already-evaluated arguments is pure,” nothing more.
Diagnosis workflow
-
Confirm the call is retained. Build the demo and
grep -c "createWidget" dist/out.js; a non-zero count is the symptom. The root cause is not a misconfiguration — it is the bundler working as designed, keeping a call it cannot prove safe. Do this before reaching for any fix, because if the call is already absent (for example a minifier already inferred purity, or the binding was eliminated for another reason) then adding an annotation is noise. The grep is the single source of truth: read the emitted chunk, not your assumptions about what should have happened. -
Confirm the call is actually pure. This is the step people skip and later regret. Open the factory’s implementation and trace every path: does it write to
window,globalThis, a module-levelMap, or a singleton registry? Does it calladdEventListener,fetch,localStorage, or a logging sink? Does it throw on invalid input in a way the program relies on? If any answer is yes, the call has an observable side effect and must never be annotated, because the annotation is a promise the bundler will act on by deleting the call and its effect together. A pure factory returns a fresh value computed only from its arguments and closed-over constants, and touching nothing outside itself. When in doubt, treat it as impure — a retained call costs bytes; a wrongly deleted call costs a production bug. -
Decide where the annotation lives. There are two placements and they have different maintenance costs. At the application call site,
const unused = /*#__PURE__*/ createWidget('a'), the promise is local and visible but must be repeated everywhere the factory is called. In the library’s own build, the author emits the annotation once on the factory’s exported value or via a Babel transform, so every consumer inherits droppability for free. Prefer the library approach when you control the package; the call-site approach is for third-party factories that shipped without annotations, where you are patching purity in at the point of use.
Annotated solution config
The fix is one comment in exactly the right position. The bundler matches the annotation to the call node that immediately follows it in the token stream, so the comment must sit directly before the callee, with nothing but whitespace between them. /*#__PURE__*/ createWidget('a') works; /*#__PURE__*/ const unused = createWidget('a') does not, because the comment now precedes the const keyword rather than the call expression, and the parser attaches it to the wrong node. Getting this wrong is the most common reason an annotation silently does nothing — the build succeeds, the call stays, and there is no warning.
// src/index.js — annotate the pure call so its unused result is droppable.
import { createWidget } from './lib.js';
// The comment must sit immediately before the call expression.
const unused = /*#__PURE__*/ createWidget('a'); // now removable
export const used = 42;
// For a library author: emit the annotation automatically for factories.
// Rollup/esbuild honor a leading /*#__PURE__*/ on call and new expressions.
// Babel's @babel/helper-annotate-as-pure adds them during a transform.
export const makeStore = /*#__PURE__*/ createStoreFactory();
// rollup.config.js — treeshake preset that trusts PURE annotations.
export default {
input: 'src/index.js',
output: { file: 'dist/out.js', format: 'esm' },
treeshake: { annotations: true }, // default true; honors /*#__PURE__*/
};
Two conditions must both hold for the call to disappear: the comment is placed correctly, and the bundler is configured to honor annotations. Rollup’s treeshake.annotations defaults to true, so the second condition is usually free — but it is silently disabled whenever treeshake is set to false wholesale, which some setups do to speed up development builds. If you disable tree-shaking to debug, do not conclude the annotation is broken; it is being ignored by design. esbuild honors the same comment with no configuration when minify or treeShaking: true is active, and Terser reads it through its compress.pure_funcs and comment-preservation settings. The comment is a shared vocabulary across the toolchain, which is why annotating in source rather than in one tool’s config makes the promise portable.
For a call whose result you do want, but only conditionally, the annotation still pays off under conditional imports. Consider a feature flag that gates an expensive initializer:
// src/feature.js — Rollup 4 / esbuild 0.21+
// The annotation lets the whole branch drop when ENABLE_ANALYTICS is false
// and the bundler constant-folds the condition at build time.
import { createAnalytics } from './analytics.js';
const ENABLE_ANALYTICS = false; // injected via define/replace at build time
export const analytics = ENABLE_ANALYTICS
? /*#__PURE__*/ createAnalytics({ endpoint: '/collect' })
: null;
Here the bundler folds ENABLE_ANALYTICS to false, selects the null branch, and the annotated createAnalytics(...) call in the dead branch is removed along with its import of ./analytics.js — so the analytics module and its transitive dependencies never reach the bundle. Without the annotation the dead branch’s call is treated as a possible side effect and the analyzer keeps ./analytics.js alive defensively, defeating the flag.
new expression.Verification
# Rollup 4 — build and confirm the annotated call was removed.
npx rollup -c
grep -c "createWidget" dist/out.js # expect 0 — the pure call was dropped
With the annotation and treeshake.annotations on, the unused pure call disappears from the output. Remove the annotation and the same call reappears — the difference is entirely the promise you gave the bundler. This A/B test is the only reliable verification, because reasoning about tree-shaking from the source alone is unreliable: interactions between minification, scope hoisting, and constant folding make it hard to predict which pass removes what. Build, grep, toggle, grep again. A count that drops from one to zero when you add the comment, and returns to one when you remove it, isolates the annotation as the sole cause and rules out coincidental elimination.
Grep is sufficient for the tiny demo but scales poorly. For real builds, verify against the bundle’s own accounting rather than string-matching an identifier that might appear in unrelated positions. A more durable check reads the module graph the analyzer produced:
# Rollup 4 — emit a machine-readable module list and assert the factory's
# source module was fully eliminated, not just its identifier hidden.
npx rollup -c --silent
node -e "const s=require('fs').readFileSync('dist/out.js','utf8'); process.exit(/createWidget\s*\(/.test(s)?1:0)"
# exit 0 = the call form is absent; exit 1 = a live call survived
Matching the call form createWidget( rather than the bare name avoids false positives from a comment, a sourcemap fragment, or an unrelated substring, and the process exit code makes the assertion usable directly as a gate.
Performance and CI considerations
Annotations cost the bundler almost nothing: recognizing a leading comment is part of parsing that already happens, and the purity flag is a single boolean on a node. There is no separate analysis pass and no measurable build-time penalty even across thousands of annotated calls, which is why libraries emit them liberally. The payoff is asymmetric — negligible build cost, potentially large runtime and download savings — so the only real question is correctness, never performance.
The failure mode worth guarding in CI is regression, not the initial fix. Once you have shrunk a bundle by making factory calls droppable, a later refactor can quietly reintroduce a retained call: someone removes an annotation, a transform starts stripping comments, or a factory grows a side effect that makes the annotation a lie. Wire the assertion above into the build as a size or content gate so the regression fails loudly. A byte-budget check on the emitted chunk catches the size drift; a content assertion that a known-eliminated module’s identifier is absent catches the specific call coming back. Both are cheap and both belong in the same job that runs the production build, so the diff that reintroduces the byte is the diff that turns the pipeline red.
Gotchas & edge cases
-
Placement is exact. The symptom is an annotation that appears correct but changes nothing; the root cause is that the comment attaches to the token immediately after it, so a comment before
const, before anawait, or on the line above the call binds to the wrong node. The fix is to move it to sit directly against the callee,/*#__PURE__*/ createWidget(...). Confirm by grepping the output — a correctly placed annotation makes the call vanish, a misplaced one leaves the count unchanged, giving you an immediate signal. -
Never annotate impure calls. The symptom is subtle and dangerous: the build shrinks, tests that do not exercise the side effect pass, and production breaks because an event listener was never registered or a global was never initialized. The root cause is a false promise — you told the bundler a call with an observable effect was pure, and it deleted the effect along with the call. The fix is to remove the annotation from any call that mutates state, registers, logs, or performs I/O. Confirm purity by reading the implementation, not by observing that the current tests still pass, because the missing effect may only surface under conditions your tests do not cover.
-
Comment stripping. The symptom is annotations that work locally but disappear in one build configuration; the root cause is a transform earlier in the chain — a Babel preset, a loader, or a formatter — that strips comments before the bundler parses them, taking the annotation with them. The fix is to annotate in source and ensure the minifier runs last, after the bundler has consumed the flag, and to set
comments: falseonly on the final minification step. Confirm by inspecting the intermediate output between transform stages for the#__PURE__#string; if it is already gone before the tree-shaking pass, that stage is the culprit. -
Impure arguments. The symptom is a partially removed call: the factory is gone but a nested call survives. The root cause is that
/*#__PURE__*/ f(sideEffect())promises only that invokingfis pure —sideEffect()is a separate expression the bundler evaluates for its effect and retains. The fix, ifsideEffect()is also pure, is to annotate it too:/*#__PURE__*/ f(/*#__PURE__*/ sideEffect()). If it genuinely has an effect, the retention is correct and you should not annotate it. Confirm by grepping for the inner call specifically; its presence tells you the argument, not the outer call, is holding bytes in the bundle.
When not to use this
Reach for sideEffects in package.json before per-call annotations when the whole module is pure — it is a coarser, cheaper promise made once at the module level rather than repeated at every call, covered in marking a package side-effect-free with sideEffects. Use /*#__PURE__*/ for the narrower case: a module that has some real side effects but also exposes pure factory calls whose results are conditionally unused. Do not annotate calls whose results you always use — the promise buys nothing there, since a referenced binding is retained regardless of purity, and the extra comments are just noise for the next reader to reason about. And never treat annotations as a way to force-delete code you know is impure but wish were not; that is not optimization, it is introducing a bug with a comment.
Related
- Tree-shaking mechanics and dead code elimination — the parent cluster on elimination.
- Marking a package side-effect-free with sideEffects — the module-level equivalent of this call-level promise.
- Bundle analysis and size budgeting — measuring the bytes these removals save.