---
title: 'Tree Shaking'
source: 'https://academia.sh/en/courses/rendering-strategies/tree-shaking'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# Tree Shaking

Bringing the graph down from module level to name level; propagation of live exports, the module with side effects that cannot be eliminated, the three settings of the package manifest's side-effect declaration, and the source syntaxes that block elimination.

The previous lesson determined which modules the chunks carry. What it did not determine
is what gets carried *inside* those modules. The dependency graph says a module is
needed; it does not say which name inside it is used. If the North Slope Measurement
Station application's units module exports six names and two of them are never called
from anywhere, the graph cannot see that.

**Tree shaking** is the work of bringing the graph down from module level to name level
and dropping unreachable exports from the output. The Bundler Concept lesson in the
Modules, Tooling and the Ecosystem course listed this transform's three conditions. This
lesson tests those conditions by running them: it builds a source tree where one is
violated, measures where elimination stops, and shows how the package makes this
possible through a declaration.

## Liveness at Name Level

In this graph, the nodes are not modules but `module#name` pairs. A pair that is **live**
stays in the output; one that is not, drops.

Propagation starts from the entry point. The entry point's exports are unconditionally
live; whatever requests them is outside the build. Every name that appears in a live
export's body is live as well: if the body touches an imported name, that name's
counterpart in the source module comes alive. Re-exports extend the chain — an
`export { a } from "./x.js"` line in an aggregator file brings `a` inside `x.js` alive
when `a` is requested, and touches nothing else.

Dynamic import creates uncertainty somewhere in this chain. The result of an `import()`
call is a namespace object, and which names get used cannot always be read off the call
site. The conservative behavior is to count all of the dynamic target's exports as live.

## The Source Tree's Real Surface

The source tree used so far carried only the names actually used. A real set of utility
modules does not look like that: it offers a surface not all of its consumers need,
names are handed out from a single aggregator file, and one of them does work at the top
level.

The block below continues the `source/` tree set up in the Module Bundling and Code
Splitting lessons; the entry point comes from there, and those lessons' setup blocks
must run first.

```bash
mkdir -p source

cat > source/units.js <<'EOF'
export const TEMP_MIN = -60;
export const TEMP_MAX = 60;
export const isWithinRange = (c) => c >= TEMP_MIN && c <= TEMP_MAX;
export const fahrenheit = (c) => c * 9 / 5 + 32;
export const mmHg = (hpa) => hpa * 0.75006157584566;
export const windChill = (c, speed) =>
  13.12 + 0.6215 * c - 11.37 * speed ** 0.16 + 0.3965 * c * speed ** 0.16;
EOF

cat > source/date.js <<'EOF'
const two = (s) => String(s).padStart(2, "0");
export const hourMinute = (d) => two(new Date(d).getUTCHours()) + ":" + two(new Date(d).getUTCMinutes());
export const dayKey = (d) => new Date(d).toISOString().slice(0, 10);
export const weekKey = (d) => {
  const t = new Date(d);
  const day = Math.floor((t - Date.UTC(t.getUTCFullYear(), 0, 1)) / 86400000);
  return t.getUTCFullYear() + "-W" + two(Math.ceil((day + 1) / 7));
};
EOF

cat > source/forecast.js <<'EOF'
import { TEMP_MIN, TEMP_MAX } from "./units.js";

const isValid = (m) => m.temperature > TEMP_MIN && m.temperature < TEMP_MAX;

export const trend = (measurements) => {
  const list = measurements.filter(isValid);
  if (list.length < 2) return 0;
  return (list.at(-1).temperature - list[0].temperature) / (list.length - 1);
};
EOF

cat > source/registry.js <<'EOF'
export const formatters = new Map();
export const register = (name, fn) => formatters.set(name, fn);
EOF

cat > source/scale-registry.js <<'EOF'
import { register } from "./registry.js";
import { fahrenheit } from "./units.js";

register("fahrenheit", fahrenheit);
EOF

cat > source/toolkit.js <<'EOF'
import "./scale-registry.js";
export { isWithinRange, fahrenheit, mmHg, windChill } from "./units.js";
export { hourMinute, dayKey, weekKey } from "./date.js";
export { trend } from "./forecast.js";
export { formatters } from "./registry.js";
EOF

cat > source/format.js <<'EOF'
import { hourMinute, isWithinRange, fahrenheit, formatters } from "./toolkit.js";

export function measurementLine(m) {
  const flag = isWithinRange(m.temperature) ? "" : " (out of range)";
  const secondary = formatters.has("fahrenheit")
    ? " / " + formatters.get("fahrenheit")(m.temperature).toFixed(1) + " F" : "";
  return hourMinute(m.timestamp) + "  " + m.temperature.toFixed(1) + " C" + secondary + "  %" + m.humidity + flag;
}
export const dualUnit = (m) => m.temperature.toFixed(1) + " C / " + fahrenheit(m.temperature).toFixed(1) + " F";
EOF

cat > package.json <<'EOF'
{
  "name": "north-slope",
  "type": "module",
  "sideEffects": ["./source/scale-registry.js"]
}
EOF
```

Three structures were added. The units and date modules carry names that go unused. The
aggregator file hands names out from a single place and also holds a bare import inside
it. The scale-registry module exports nothing; it only writes to a registry at the top
level, and the format module changes its output based on what that registry holds.

## The Resolver

The script below parses modules line by line, propagates liveness, and computes output
size under three separate side-effect policies.

```js
// shake.mjs — name-level liveness analysis and three sideEffects policies.
import { readFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";

const ENTRY = "source/entry-browser.js";
const STATEMENT_START = /^(import\b|export\b|const\b|let\b|function\b|class\b)/;

function parse(filePath) {
  const lines = readFileSync(filePath, "utf8").split("\n");
  const resolve = (b) => relative(".", join(dirname(filePath), b));
  const imports = new Map();             // local name -> { module, exportedAs }
  const bareImports = [];                // modules imported only for their side effect
  const pieces = [];                     // { name, module, exportedAs, body, exported, size }
  for (let i = 0; i < lines.length; i++) {
    const s = lines[i];
    let m;
    if ((m = s.match(/^import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/))) {
      for (const p of m[1].split(",").map((x) => x.trim()).filter(Boolean)) {
        const [exportedAs, local = exportedAs] = p.split(/\s+as\s+/);
        imports.set(local, { module: resolve(m[2]), exportedAs });
      }
    } else if ((m = s.match(/^import\s*["']([^"']+)["']/))) {
      bareImports.push(resolve(m[1]));
    } else if ((m = s.match(/^export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/))) {
      for (const p of m[1].split(",").map((x) => x.trim()).filter(Boolean)) {
        pieces.push({ name: p, module: resolve(m[2]), exportedAs: p, body: "", exported: true,
          size: Buffer.byteLength(p) + 2 });
      }
    } else if ((m = s.match(/^(?:export\s+)?(?:const|let|function|async function|class)\s+(\w+)/))) {
      const body = [s];
      while (i + 1 < lines.length && !STATEMENT_START.test(lines[i + 1])) body.push(lines[++i]);
      const text = body.join("\n").replace(/\s+$/, "");
      pieces.push({ name: m[1], body: text, exported: s.startsWith("export"),
        size: Buffer.byteLength(text) + 1 });
    } else if (s.trim() && !s.trim().startsWith("//")) {
      pieces.push({ name: null, body: s, exported: false, size: Buffer.byteLength(s) + 1 });
    }
  }
  return { imports, bareImports, pieces, size: Buffer.byteLength(readFileSync(filePath)) };
}

const modules = new Map();
const queue = [ENTRY];
while (queue.length > 0) {
  const filePath = queue.shift();
  if (modules.has(filePath)) continue;
  const m = parse(filePath);
  modules.set(filePath, m);
  const dynamicImports = [...readFileSync(filePath, "utf8").matchAll(/\bimport\(\s*["']([^"']+)["']\s*\)/g)]
    .map(([, b]) => relative(".", join(dirname(filePath), b)));
  queue.push(...[...m.imports.values()].map((i) => i.module), ...m.bareImports, ...dynamicImports,
    ...m.pieces.filter((p) => p.module).map((p) => p.module));
  m.dynamicImports = dynamicImports;
}

function resolvePolicy(policy) {              // policy: "missing" | "false" | "list"
  const LIST = JSON.parse(readFileSync("package.json", "utf8")).sideEffects;
  const live = new Set();                      // "module#name"
  const retained = new Set();
  const pending = [];

  function scanBody(filePath, body) {
    const m = modules.get(filePath);
    for (const [local, source] of m.imports)
      if (new RegExp("\\b" + local + "\\b").test(body)) pending.push([source.module, source.exportedAs]);
    for (const p of m.pieces)
      if (p.name && new RegExp("\\b" + p.name + "\\b").test(body) && !body.startsWith("export"))
        pending.push([filePath, p.name]);
  }

  function retain(filePath) {
    if (retained.has(filePath)) return;
    retained.add(filePath);
    const m = modules.get(filePath);
    for (const p of m.pieces) if (!p.exported) scanBody(filePath, p.body);
    for (const b of m.bareImports) {
      const keep = policy === "missing" || (policy === "list" && LIST.includes("./" + b));
      if (keep) retain(b);
    }
    for (const d of m.dynamicImports) {
      retain(d);
      for (const p of modules.get(d).pieces) if (p.exported) pending.push([d, p.name]);
    }
    if (policy === "missing") {
      for (const i of m.imports.values()) retain(i.module);
      for (const p of m.pieces) if (p.module) retain(p.module);
    }
  }

  function markLive(filePath, name) {
    const key = filePath + "#" + name;
    if (live.has(key)) return;
    live.add(key);
    retain(filePath);
    const p = modules.get(filePath).pieces.find((x) => x.name === name && x.exported);
    if (!p) return;
    if (p.module) pending.push([p.module, p.exportedAs]);
    else scanBody(filePath, p.body);
  }

  retain(ENTRY);
  for (const p of modules.get(ENTRY).pieces) if (p.exported) pending.push([ENTRY, p.name]);
  while (pending.length > 0) markLive(...pending.pop());

  let total = 0;
  const dropped = [], deadNames = [];
  for (const [filePath, m] of modules) {
    if (!retained.has(filePath)) { dropped.push(filePath); continue; }
    let size = m.size;
    for (const p of m.pieces)
      if (p.exported && !live.has(filePath + "#" + p.name)) {
        size -= p.size;
        deadNames.push(filePath.replace("source/", "") + "#" + p.name);
      }
    total += size;
  }
  return { total, dropped, deadNames };
}

const raw = [...modules.values()].reduce((t, m) => t + m.size, 0);
console.log("modules in graph:", modules.size, " raw size:", raw, "B");
for (const policy of ["missing", "false", "list"]) {
  const r = resolvePolicy(policy);
  console.log("-- sideEffects: " + policy + " --");
  console.log("  output size : " + r.total + " B  (gain " + (raw - r.total) + " B)");
  console.log("  dropped module : " + (r.dropped.map((p) => p.replace("source/", "")).join(", ") || "-"));
  console.log("  dead name      : " + (r.deadNames.sort().join(", ") || "-"));
}
```

```
$ node shake.mjs
modules in graph: 13  raw size: 4280 B
-- sideEffects: missing --
  output size : 3665 B  (gain 615 B)
  dropped module : -
  dead name      : date.js#weekKey, forecast.js#trend, toolkit.js#dayKey, toolkit.js#mmHg, toolkit.js#trend, toolkit.js#weekKey, toolkit.js#windChill, units.js#mmHg, units.js#windChill
-- sideEffects: false --
  output size : 3353 B  (gain 927 B)
  dropped module : scale-registry.js, forecast.js
  dead name      : date.js#weekKey, registry.js#register, toolkit.js#dayKey, toolkit.js#mmHg, toolkit.js#trend, toolkit.js#weekKey, toolkit.js#windChill, units.js#mmHg, units.js#windChill
-- sideEffects: list --
  output size : 3537 B  (gain 743 B)
  dropped module : forecast.js
  dead name      : date.js#weekKey, toolkit.js#dayKey, toolkit.js#mmHg, toolkit.js#trend, toolkit.js#weekKey, toolkit.js#windChill, units.js#mmHg, units.js#windChill
```

The three policies produce three separate outputs from the same source tree. The
dead-name list carries a common core across all of them: millimeters of mercury, wind
chill, the week key, and the trend calculation appear in no live body. The re-export
lines in the aggregator file drop along with them — the line that carries a name is
meaningless once the name itself is dead.

## The Side-Effect Barrier

The single file that makes the difference between policies is the scale registry. This
module has no exports; it enters the graph only through a bare import and calls a
function at its top level. Name-level resolution jams here: there is no name to bring
alive, yet the module's running changes another module's behavior.

The proof is done by running it. The block below produces a copy with elimination
applied and runs the same call against both outputs. The `sed -i` option behaves
differently between the BSD and GNU versions; giving it a backup extension gets the same
result on both.

```bash
#!/usr/bin/env bash
# Produces a copy with the side-effect module pruned, runs the same call against both outputs.
rm -rf pruned && mkdir pruned && cp source/*.js pruned/
sed -i.bak -e '/^import ".\/scale-registry.js";$/d' -e '/from ".\/forecast.js"/d' pruned/toolkit.js
rm -f pruned/*.bak pruned/scale-registry.js pruned/forecast.js

MEASUREMENT='{ timestamp: "2026-02-11T06:00:00Z", temperature: -4.2, humidity: 72 }'
node --input-type=module -e "
import { measurementLine } from './source/format.js';
console.log('preserved:', measurementLine(${MEASUREMENT}));
"
node --input-type=module -e "
import { measurementLine } from './pruned/format.js';
console.log('pruned   :', measurementLine(${MEASUREMENT}));
"
```

```
preserved: 06:00  -4.2 C / 24.4 F  %72
pruned   : 06:00  -4.2 C  %72
```

The second line has no Fahrenheit column. The elimination produced no error, threw no
exception; it silently changed the output. This is tree shaking's most dangerous failure
mode: the build looks successful, and a behavior the tests do not cover disappears.

This is why the tool does not drop a module with undeclared side-effect status on its
own. The information comes from the `sideEffects` field in the package's manifest, and it
has three settings.

**If the field is absent**, the tool behaves conservatively: it drops no module in its
entirety, only deletes dead export declarations. Output is 3665 bytes and correct, but
the forecast module stays in the output hollowed out.

**If the field is `false`**, the author has promised that no module in the package does
work at the top level. The tool drops the scale registry and the forecast module, and
the `register` name in the registry module dies along with it. Output shrinks to 3353
bytes — and produces the second line's behavior above. For this package, `false` is a
lie.

**If the field is a list**, the author states which files are side-effecting, one by
one. The scale registry is preserved, the forecast module drops. Output is 3537 bytes:
the smallest output obtainable without breaking correctness.

The field is therefore not a setting but a commitment. Filled in incorrectly, the tool
eliminates wrongly, and the failure shows up not where the elimination happened but
where the lost behavior gets used.

## Other Syntaxes That Stop Elimination

Side effects are not the only barrier. All of the syntaxes below break name-level
tracking and force the tool to keep the module in its entirety.

**Passing the namespace object around whole.** If the object is handed to a function
after `import * as toolkit from "./toolkit.js"`, which names get read cannot be known
statically. This uncertainty does not arise when names are imported one at a time.

**Computed access.** A read in the form `toolkit[selectedName]` corresponds to a name
determined at runtime. A tool that sees such an access has to count every name in that
module as live.

**Call-based module format.** In CommonJS, the exports object can be mutated at runtime;
which names exist is not certain until evaluation finishes. Because the list obtained by
static parsing can be incomplete, elimination is not applied.

**Declarations that do work at evaluation time.** If a top-level call's result is bound
to a constant, whether that call has a side effect is not known. Tools get past this
uncertainty with purity annotations placed in the source; the annotation is a commitment
just like the declaration field.

**Aggregator files.** The aggregator itself is harmless — the example's aggregator had
its dead re-exports drop. What is harmful is the aggregator pulling a side-effecting
module into the graph through a bare import: that single line carries that module to
every consumer that imports the aggregator.

This list leaves the library author four concrete responsibilities: not doing work at
the top level, exporting names directly, listing side-effecting files in the
declaration, and not putting a bare import in the aggregator.

## Summary

- Tree shaking brings the graph down to `module#name` level; liveness propagates from
  the entry point's exports through names that appear in bodies.
- Re-exports carry liveness by name; a dynamic import target's every export is
  conservatively counted live.
- A module that has no exports and does work at the top level cannot be eliminated by
  name-level resolution; if it is eliminated, the failure is silent — in the example,
  the Fahrenheit column disappears without an error.
- The side-effect field in the package manifest produces three outcomes: absent gives
  3665 bytes and no module drops, `false` gives 3353 bytes and breaks behavior, a
  correct list gives 3537 bytes and preserves behavior.
- Passing the namespace object around whole, computed access, call-based module format,
  and declarations that do work at evaluation time all stop elimination.

## Next Step

The graph has, up to this point, been built only from script files. But an interface's
output does not consist only of code: the measurement list's style file, the station
icon, the interface's font, and a handful of static files to be copied to the browser
as-is also go into the output. Some of these are imported from code and become nodes in
the graph; some are never imported but must still be published. The next lesson takes up
this second flow: running assets through a pipeline, embedding small ones in the body,
writing large ones as separate files, and linking names in the source to names in the
output.
