How Tree Shaking Prunes Unused JavaScript Exports

Tree shaking is a dead-code elimination technique used by modern JavaScript bundlers to optimize bundle size by discarding unused code. This article explains the underlying mechanics of tree shaking, focusing on how static ES module syntax enables static analysis, how bundlers traverse Abstract Syntax Trees (ASTs) to map dependency graphs, and how unreachable exports are identified and stripped during the build process.

The Role of Static ES Module Syntax

Tree shaking relies entirely on the static nature of ECMAScript Modules (ESM), defined by import and export statements. Unlike CommonJS (require() and module.exports), which allows dynamic module loading at runtime inside conditions or functions, ESM statements must appear at the top level of a file.

Because the module structure is completely deterministic, a bundler does not need to execute the JavaScript code to determine what is being exported and imported. The relationships between modules are fully resolved ahead of time during compilation.

Parsing Modules into Abstract Syntax Trees (AST)

When a bundler—such as Rollup, Webpack, or Esbuild—processes a project, it begins by parsing each source file into an Abstract Syntax Tree (AST). An AST is a tree representation of the syntactic structure of the source code.

During AST generation, the parser specifically looks for: * ExportNamedDeclaration and ExportDefaultDeclaration nodes to catalog every export a module provides. * ImportDeclaration and ImportSpecifier nodes to record which identifiers are requested by consuming modules.

By converting code into an AST, the bundler transforms raw text into queryable nodes, allowing it to trace individual variable and function names across module boundaries.

Constructing the Dependency Graph and Reachability Analysis

Once ASTs are generated, the bundler builds a global module graph starting from the configured entry point (e.g., index.js). It performs reachability analysis using graph traversal algorithms:

  1. Marking Live Bindings: The bundler starts at the entry point and marks all imported variables as “live” or “referenced.”
  2. Recursive Traversal: It follows these references into the imported modules, marking the corresponding export statements and any internal identifiers they depend on.
  3. Isolating Unreferenced Exports: Any export statement in the graph that is never marked as a live reference remains flagged as unused.

If a module exports three functions (add, subtract, multiply) but the consuming module only imports add, the graph marks subtract and multiply as unreferenced symbols.

Handling Side Effects

A primary challenge in tree shaking is side effects—code that modifies global state or performs an action simply by being imported (e.g., polyfills or modifying window).

Bundlers analyze whether an unreferenced export or file has side effects: * Package Configuration: Developers use the "sideEffects": false field in package.json to explicitly declare that a package contains no side effects, allowing entire unused files to be dropped immediately. * Manual Annotations: Bundlers recognize annotations like /*#__PURE__*/ on function calls, informing the parser that a function call does not produce side effects and can be safely discarded if its return value is unused.

Code Rewriting and Dead Code Elimination (DCE)

Once the static analysis identifies unused exports and verifies side-effect safety, the pruning happens in two stages:

  1. Unlinking Exports: The bundler rewrites the module code, removing the export designation from unused variables and functions so they become local, unreferenced declarations.
  2. Minification and Removal: The bundler passes the processed bundle to a minifier (such as Terser or Esbuild). The minifier detects these unreferenced local variables and drops them from the final production output via standard Dead Code Elimination.