· 4 min read

The function with a backpack

Follow one counter closure through lowering: a code pointer paired with an environment record, a lifetime decided by escape, and an optimizer trying to unpack the bag.

You can write closures for a decade without ever being asked where count lives. The counter below is the interview classic: two arrows share one variable, and the function that created it returns before either arrow runs. Shared, and outliving its frame — those two facts are the entire puzzle a compiler has to solve.

counter.jsjs
function makeCounter() {  let count = 0;  return {    next: () => ++count,    read: () => count,  };}const counter = makeCounter();counter.next();counter.read(); // 1

The backpack in the title is the answer in one image. A closure is code that travels with a small bag of the variables it could not leave behind, and once you follow this one counter through lowering, the magic evaporates into two decisions: what goes in the bag, and how long the bag has to live.

  1. Find the free binding

    Both inner functions use count from makeCounter.

  2. Build one environment

    The mutable binding becomes a shared field.

  3. Return two closures

    Each value pairs code with the same environment pointer.

  4. Prove what can disappear

    Inlining or escape analysis may remove the pair at known call sites.

One captured binding moves from lexical scope into a shared environment, then becomes an optimization question.

Lower the free binding#

Relative to each arrow, count is free — the arrow uses the name without binding it. A closure-conversion pass lifts each body into an ordinary function that takes an explicit environment parameter, then pairs that function's address with the environment it needs.

counter.jsjs
function makeCounter() {  let count = 0;  return {    next: () => ++count,    read: () => count,  };}const counter = makeCounter();counter.next();counter.read(); // 1

Both closure records point at the same CounterEnvironment, and they have to. Give each record its own copy of zero and the syntax survives while the program quietly breaks — next would increment a count that read never sees.

next: Closurecode: next_codeenvironmentread: Closurecode: read_codeenvironmentCounterEnvironmentcount: 0 → 1

Two closure records sharing one counter environment

  • next: Closure: code: next_code; environment points to CounterEnvironment
  • read: Closure: code: read_code; environment points to CounterEnvironment
  • CounterEnvironment: count: 0 → 1
After one counter.next(), the field both environment pointers reach already holds 1.

What forces this is the mutation. If both arrows only read some constant, the compiler could hand each closure a private copy and nobody could tell the difference. ++count writes the binding itself, so the capture must behave like a place, not a value.

The specification pins down only the relationship: a function object carries an [[Environment]] internal slot linking it to the scope it closed over. Context object, stack slot, register, folded constant — an engine may pick any representation that preserves the behavior.

The return changes the lifetime#

makeCounter finishes while the returned object is still reachable, so storage left in its stack frame would dangle. An ordinary implementation moves the environment to managed memory instead; V8's preparser write-up shows the engine tracking exactly which outer variables inner functions use, to tell stack locations from context locations.

From then on, lifetime follows the bag rather than the call. Keep either returned closure alive and the environment stays; drop them both and the collector may take it. There is no lifetime syntax anywhere in the source — the engine derives the boundary entirely from reachability.

None of this means every closure costs a heap allocation. A closure that never escapes can keep its captures on the stack, and a compiler that can see the call target may pass count as a hidden argument. Escape is the conservative answer here only because the pair is returned to code the compiler cannot see.

The optimizer follows the trail backward#

Now run the story in reverse. If a whole-program optimizer inlines makeCounter into a caller that invokes next and read immediately — never storing the object, never passing it to unknown code — the code pointers become known calls, escape analysis keeps the environment local, and scalar replacement turns the field back into one mutable variable. The bag gets unpacked into a plain let again.

Whether your particular counter gets that treatment is nothing I would promise from the source alone. The specification guarantees behavior, never representation, and an unknown call, a debugger, or one stored reference is enough to keep the full pair around.

Either way the destination is fixed. The final counter.read() reaches read_code through the environment pointer, into the same field next_code incremented — and whatever the optimizer managed to erase along the way, it still owes you the 1.