Step 1 — Reachability, and What Closures Actually Retain

Goal

Replace "the GC frees what you stop using" with "the GC frees what is unreachable", and discover that closures retain contexts, not variables.

Prerequisites

  • fe-01 complete
  • cd src && npm install (or the symlinked node_modules from fe-01)

Predict first

Three factories. All three return the identical closure () => 42. You keep 200 of the returned closures alive.

// A
const makeClean = () => {
  const big = new Array(50000).fill('x');
  return () => 42;
};

// B
const makeLeaky = () => {
  const big = new Array(50000).fill('x');
  const unusedSibling = () => big.length;   // never called, never returned
  return () => 42;
};

// C
const makeFixed = () => {
  let big = new Array(50000).fill('x');
  const unusedSibling = () => big.length;
  big = null;
  return () => 42;
};

Write down the retained heap for each. Commit to numbers.

Run

npm run closures

Expected output:

  variant                            | retained heap
  -----------------------------------|---------------
  A  no sibling references it        |    0.01MB
  B  unused sibling references it    |   38.17MB
  C  sibling + explicit big = null   |    0.01MB

  B retains 5744x what A retains.

What just happened

V8 allocates one Context per scope. If any closure created in that scope references a variable, that variable is context-allocated — moved from the stack into a heap object shared by every closure from that scope. The returned () => 42 holds the Context, and the Context holds big.

unusedSibling is never called and never escapes. It does not need to run, or even be reachable by name, to have caused big to be context-allocated at compile time.

Three consequences worth carrying:

  1. "My callback doesn't reference that variable" is not a defence. The retaining code is a sibling function, often written by someone else, often the innocuous-looking one.
  2. x = null is sometimes load-bearing, not cargo cult — variant C. The distinguishing question is whether a surviving closure shares the scope. If none does, nulling is noise.
  3. This is a V8 implementation detail, not a language guarantee. Other engines may allocate contexts differently. Record it as engine behaviour, with the version, in the verification log.

The general rule this establishes and the rest of the module builds on:

Nothing is freed because you stopped using it. Things are freed because nothing can reach them.

Every remaining experiment is an instance of that sentence.

Checkpoint

docs/verification.md Checkpoint 1.

Going deeper

N=1000 SIZE=100000 npm run closures

Does B scale linearly with N? With SIZE? What does that tell you about whether the Context is shared per call or per scope?