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.
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.