Skip to content
academia.sh

Lesson 10 / 19

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.

Contents

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.

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.

// 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.

#!/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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close