How it works
Trace the example with spec names. The module body runs in a Module Environment Record holding makeCounter, a, b, label, and getLabel. The function declaration makeCounter is instantiated when the module environment is set up, with that record as its [[Environment]] — which is why a declaration is callable before its line.
makeCounter() creates call environment #1 with [[OuterEnv]] = the module record. let count = 0 creates a binding there. Evaluating each arrow runs InstantiateArrowFunctionExpression, which passes the running execution context's LexicalEnvironment — record #1 — to OrdinaryFunctionCreate. The call returns; the execution context is popped, but record #1 is still referenced by two function objects, so it stays alive.
Module Environment Record { makeCounter, a, b, label, getLabel }
├─ call #1 record { count: 2 } ← [[Environment]] of a.increment, a.read
└─ call #2 record { count: 1 } ← [[Environment]] of b.increment, b.read
(each call record's [[OuterEnv]] is the module record)
When a.increment() runs, NewFunctionEnvironment creates a short-lived record whose [[OuterEnv]] is record #1. Resolving count misses in the arrow's own record, follows [[OuterEnv]], and finds the binding in #1; ++count writes to that binding. b's functions follow a different chain.
Two clarifications. First, the "current environment" at creation is the innermost one: a function created inside a block captures the block's Declarative Environment Record, not just the function's. That's the hook for (let …) loops use to give each iteration its own binding. Second, the spec states that Environment Records are purely specification mechanisms that need not correspond to anything in an implementation. Engines may store captured variables however they like, as long as the observable behaviour matches. In V8, each closure is a JSFunction pointing to shared, compiled code and to the Context it was created in.
Edge cases
A named function expression gets one extra environment. InstantiateOrdinaryFunctionExpression creates a Declarative Environment Record between the function and its surroundings, holding an immutable binding of the function's own name. That's why the name is visible inside but not outside, and why reassigning it fails:
const countdown = function tick(n) {
if (n === 0) return 'done';
return tick(n - 1); // `tick` resolves in a private environment
};
console.log(typeof tick); // → undefined
console.log(countdown(3)); // → done
const g = function self() {
self = null; // immutable binding: TypeError in strict code
};
try { g(); } catch (e) { console.log(e.constructor.name); } // → TypeError
Parameter default values get their own environment when a function has parameter expressions. FunctionDeclarationInstantiation puts body vars in a separate record so closures created in the parameter list don't see body declarations:
const x = 'outer';
function f(get = () => x) {
var x = 'body';
return get();
}
console.log(f()); // → outer
Other edges worth naming:
new Function(...) runs CreateDynamicFunction, which uses the realm's global environment as [[Environment]]. In a module, that means it can't see module-level consts either.
- Arrow functions also have no
arguments object of their own; arguments inside an arrow resolves outward like any other name.
- A direct
eval inside a closure can name any binding in the chain at run time, so an engine can't prove any outer variable is unused.
Real-world scenario
The classic React "stale closure" is this mechanism, not a snapshot. Every render is a fresh call of the component function, so each render has its own environment with its own count. A callback registered once — an effect with an empty dependency list that starts an interval — keeps the first render's environment for its whole life:
let onTick;
function render(count) {
// Each render is a new call → a new environment with its own `count`.
onTick ??= () => console.log(`count is ${count}`); // registered once, like an effect with []
}
render(0);
render(1);
render(2);
onTick(); // → count is 0
The fixes follow from the mechanism: re-create the callback when count changes (list it as a dependency), or stop reading the captured binding at all — React's state updater form setCount(c => c + 1) receives the current value as an argument.
Trade-offs
Closures are the cheapest way to pair behaviour with state that nobody else can reach, with no this to lose. The costs are allocation and retention. Each evaluation of a function expression creates a new function object, so a factory that returns five methods allocates five functions per instance, where class or prototype methods are shared; MDN recommends prototype methods for that reason when you create many instances. In V8 the compiled code is shared through the SharedFunctionInfo, so the per-closure cost is an object, not a copy of the code. Retention matters more: a long-lived closure keeps its environment chain reachable, which is the usual way closures leak memory.