Terser: Dead Code, Side Effects and Pure Annotations
In modern JavaScript build pipelines, bundlers handle module graph tree-shaking while minifiers optimize AST-level Dead Code Elimination (DCE) and variable mangling. When unreferenced functions or initialization logic are unexpectedly erased or retained, the root cause lies in side-effect analysis. This article clarifies roles and side-effect marking.
1. Problem: overlapping and misunderstood responsibilities of bundlers and minifiers
Developers frequently blame Terser or esbuild when tree-shaking fails or code is stripped. In reality, bundlers like Rollup or Webpack determine which modules and exports belong in the bundle, whereas minifiers like Terser decide which unreferenced variables or function calls within an AST can be safely erased.
2. Minimal reproduction: unreferenced function calls retained or stripped without PURE markers
In the snippet below, createLogger only instantiates an object without modifying global scope. However, without a PURE annotation, a minifier cannot safely discard the call.
/* Source logic: unreferenced variable assignment */
const logger = createLogger({ level: 'info' });
/* Without PURE annotation: minifier keeps expression assuming potential global side effects */
createLogger({ level: 'info' });
/* With PURE annotation: minifier safely identifies call as side-effect free and erases it */
const logger = /*@__PURE__*/ createLogger({ level: 'info' });3. Root cause: limits of dynamic JavaScript side-effect inference
JavaScript is highly dynamic: global modifications, property getters, Object.defineProperty, and class constructors can trigger runtime side effects. Terser employs conservative semantic analysis, keeping calls unless purity can be proven.
4. Recommendation: explicit /*@__PURE__*/ annotations and library design rules
1. Library design: prepend /*@__PURE__*/ or /*#__PURE__*/ comments before top-level object instantiations or helper calls. 2. Application code: avoid top-level global mutations; encapsulate initialization inside explicit init() calls. 3. Configure Terser compress.pure_funcs and side_effects options appropriately.
5. Complete code: simulating minifier side-effect evaluation and DCE logic
This TypeScript snippet demonstrates how a minifier evaluates variable references, global modification flags, and PURE annotations to decide DCE erasure.
interface FunctionCallNode {
name: string;
hasPureAnnotation: boolean;
modifiesGlobal: boolean;
}
function evaluateDeadCodeElimination(node: FunctionCallNode, isReferenced: boolean): boolean {
if (isReferenced) return false;
if (node.hasPureAnnotation && !node.modifiesGlobal) return true;
return false;
}
const callA: FunctionCallNode = { name: "createLogger", hasPureAnnotation: true, modifiesGlobal: false };
const callB: FunctionCallNode = { name: "setupGlobalState", hasPureAnnotation: false, modifiesGlobal: true };
console.log(evaluateDeadCodeElimination(callA, false)); // true (safely erases pure call)
console.log(evaluateDeadCodeElimination(callB, false)); // false (conservatively retains side-effectful call)6. Incorrect approaches
Enabling aggressive unsafe Terser options indiscriminately, breaking stateful functions. Relying on top-level global mutations while expecting minifiers to erase them. Expecting minifiers to perform multi-file tree-shaking alone.
7. Boundaries: IIFEs and getter interceptions
Global mutations inside IIFEs cannot be automatically tracked. Property getter and setter evaluations are always treated as potentially side-effectful.
8. Verification
Inspect bundle output to verify target code is removed. Run identical initialization, side-effect, and dynamic-import tests against unminified and minified builds, then compare critical runtime output.
9. FAQ
How does bundler tree-shaking differ from minifier DCE? Bundlers eliminate unused module exports via ES Module import graphs; minifiers discard unreferenced AST expressions and local variables.
Is /*@__PURE__*/ supported across all tools? Yes, Terser, esbuild, Rollup, and SWC support this standard annotation.
Why was a PURE-annotated call still retained? If its return value is referenced by active code or arguments contain non-pure expressions, the call must be kept.
10. Summary
Separate bundler module graph tree-shaking from minifier AST DCE. Explicitly annotate side-effect-free calls with PURE and avoid top-level global mutations for optimal JavaScript compression.
Sources and further reading
Specifications and references for checking the article’s technical details.
- ECMAScript Language Specification
Ecma International
Related articles
Modern JavaScript Minification: Syntax Compatibility
Distinguish Parser, Transpiler (Babel/SWC), Minifier (Terser/esbuild), and Runtime configurations, fixing Parse Errors for optional chaining ?. and top-level await.
How it worksJavaScript Minification: ASTs and Source Maps
Deconstruct the JS compression lifecycle: building ASTs via Parsers, mangling variables and stripping dead branches in Transform, and emitting Source Maps.
TroubleshootingJS Obfuscation: Fix Scope and Function Call Errors
Analyze how JavaScript obfuscator variable mangling, string array extraction, and property renaming break this bindings and cause Uncaught TypeError exceptions.
Keep exploring
Use the related browser tool to test the workflow with your own sample data.
Open related tool