Language semantics5 min read

JavaScript Closures Capture Bindings, Not Frozen Values

Understand closure results after mutation, let and var loop behavior, factory scope, and the lifetime of captured state.

  • closures
  • scope
  • language semantics

A JavaScript closure retains access to bindings in its surrounding lexical environments. It does not copy each value when the function is created. If code later assigns a new value to a captured binding, the closure reads that new value when it runs.

That rule explains both useful private state and the classic loop-callback surprise. The question to ask is not “what was the value when this function was made?” but “which binding will this function read when it is called?”

A closure observes later assignment

The inner function below closes over one message binding. Reassigning that binding changes what the function returns:

function makeReader() {
  let message = 'draft';

  const read = () => message;
  message = 'published';

  return read;
}

const read = makeReader();
console.log(read()); // published

Calling makeReader creates a lexical environment for that invocation. Returning read keeps the relevant environment reachable after makeReader itself has returned. The ECMAScript environment-record model defines the binding operations behind that behavior.

Captured state can therefore implement a small stateful API:

function makeCounter(start = 0) {
  let value = start;

  return {
    increment() {
      value += 1;
      return value;
    },
    read() {
      return value;
    },
  };
}

const counter = makeCounter(10);
console.log(counter.increment()); // 11
console.log(counter.read());      // 11

Both methods share the same value binding because one call to makeCounter created them in one environment.

Each factory call gets separate state

A second factory invocation creates a different environment and different bindings:

const first = makeCounter(0);
const second = makeCounter(100);

first.increment();

console.log(first.read());  // 1
console.log(second.read()); // 100

This separation is why closures work well for per-instance state without a class. It is also why moving a variable outside the factory changes the design: every returned function would then close over the same outer binding.

The binding and the value stored in it are separate concerns. If a captured binding contains an object, code can mutate that object without assigning the binding:

function makeQueue() {
  const items = [];

  return {
    add(item) {
      items.push(item);
    },
    snapshot() {
      return [...items];
    },
  };
}

const prevents assigning a different array to items; it does not freeze the array. The methods still share one mutable object.

let creates a binding for each loop iteration

In a for loop whose initializer declares let, JavaScript creates a fresh per-iteration environment. Each callback closes over that iteration’s index binding:

const readers = [];

for (let index = 0; index < 3; index += 1) {
  readers.push(() => index);
}

console.log(readers.map((read) => read())); // [0, 1, 2]

The ECMAScript 2026 CreatePerIterationEnvironment operation creates new mutable bindings and initializes them from the preceding iteration. The callbacks do not receive frozen numeric snapshots; they receive access to three distinct bindings.

for...of with const or let follows the same practical rule: a new lexical binding is created for each iteration. That makes deferred callbacks safe without an extra wrapper function.

var gives the callbacks one shared binding

var is function-scoped, so this loop has one index binding. All callbacks read it after the loop has assigned 3:

const readers = [];

for (var index = 0; index < 3; index += 1) {
  readers.push(() => index);
}

console.log(readers.map((read) => read())); // [3, 3, 3]

Changing var to let is the direct fix when each callback should retain its iteration’s value. When var is required in older code, a factory call can deliberately create one binding per callback:

function readerFor(value) {
  return () => value;
}

const readers = [];
for (var index = 0; index < 3; index += 1) {
  readers.push(readerFor(index));
}

console.log(readers.map((read) => read())); // [0, 1, 2]

Closures can extend object lifetimes

As long as a reachable function needs a surrounding binding, the engine must preserve the behavior of that binding. A listener or timer callback can therefore keep captured application data reachable longer than intended.

function watch(button, model) {
  const onClick = () => render(model);
  button.addEventListener('click', onClick);

  return () => button.removeEventListener('click', onClick);
}

The returned cleanup function makes the ownership boundary visible. Removing the listener releases the browser’s reference to onClick; if nothing else retains the functions or model, they can become collectible. Garbage-collection timing is not observable or guaranteed, so closures should not be used as a resource-release mechanism.

Use closures when behavior genuinely belongs with surrounding state. When a result is meant to be a snapshot, copy the value into a new binding or immutable data structure deliberately. The language always captures bindings; snapshot semantics are an application choice.