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-01completecd src && npm install(or the symlinkednode_modulesfrom 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:
- "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.
x = nullis 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.- 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?