Closures
This chapter assumes you are comfortable with function declarations vs. expressions, lexical scope with let/const, and the difference between a value and a reference. We go beneath the surface: what a closure actually is at runtime, where it silently costs you memory, and when to prefer it over adjacent tools like classes or partial application libraries.
The Working Model
A closure is a function paired with the scope (the set of variable bindings) it was created in. Whenever a function is defined, it captures a reference to its enclosing environment record — the runtime structure holding that scope's bindings. The function keeps that reference alive for as long as the function itself is reachable, regardless of whether the enclosing function has returned.
The key consequence: inner functions outlive the activation of the function that created them, and the variables they reference are not copied — they are shared, live bindings.
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const next = makeCounter();
console.log(next()); // 1
console.log(next()); // 2makeCounter finishes executing after the first line of the last two statements, yet count survives. next holds a reference to makeCounter's environment. Each call to makeCounter produces a distinct environment — a second counter is independent.
const a = makeCounter();
const b = makeCounter();
a(); a(); // 3 from a's closure
b(); // 1 — separate environmentWhat the Engine Actually Does
Modern engines allocate environment records on the heap when a scope might be captured. In V8, variables that are closed over are moved from the stack frame into a heap-allocated Context object. The function's internal [[Environment]] slot points at it; when the function body reads count, a scope-chain lookup walks [[Environment]] first, then outward.
This is why closures are not snapshots. If the captured binding is later reassigned, the closure sees the new value:
let x = 1;
function read() { return x; }
x = 2;
console.log(read()); // 2 — live binding, not a copyA useful mental refinement: the closure captures the binding, not the value — but if the binding holds a reference to an object, the closure shares that object.
function attach(state) {
return { get: () => state.value, set: (v) => { state.value = v; } };
}
const s = { value: 10 };
const h = attach(s);
h.set(99);
console.log(s.value); // 99 — same object, mutated through the closureCanonical Uses
- Private state. Before private class fields (
#count), closures were the standard encapsulation tool. They remain the right choice when you want per-instance privacy without a class. - Factories and partial application. A function returning a function with some arguments pre-bound:
const logWith = (level) => (msg) => console.log(`[${level}] ${msg}`);
const warn = logWith('WARN');
warn('disk nearly full'); // [WARN] disk nearly full- Callbacks and event handlers. Every
setTimeout, promise.then, or event listener that references enclosing variables is a closure. You have been writing them constantly.
- Memoization and once-style wrappers:
function once(fn) {
let done = false;
let result;
return (...args) => {
if (!done) { result = fn(...args); done = true; }
return result;
};
}Gotchas and Failure Modes
The loop-capture bug
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// prints 3, 3, 3 — `var i` is one function-scoped bindingWith let, each iteration creates a fresh per-iteration binding, so the closures capture different is and print 0, 1, 2. This is not a lint rule; it is a semantic difference in the spec. If you must support legacy var code, wrap the body in an IIFE to force a new scope.
Stale captures in React-style render closures
Closures freeze bindings at definition time in the sense that a callback created in one invocation captures that invocation's bindings. A callback registered once but reading state that later changed elsewhere will read its captured binding, which may be stale relative to external state. The fix is to re-create the callback when the values it reads change, or pass values as arguments rather than reading them from the enclosing scope.
Over-capturing
Capturing a large object "because it's convenient" pins it in memory for the lifetime of the closure. If a listener registered on a long-lived global closes over a component's entire state tree, that tree cannot be collected until the listener is removed.
Closure vs. Class vs. Module: Decision Criteria
| Need | Closure | Class | Module |
|---|---|---|---|
| One private value + behavior | Best fit | Fine (`#field`) | Overkill |
| Many instances sharing methods | Wasteful (methods rebuilt per call) | Best (methods on prototype) | — |
| Many private fields | Awkward | `#fields` read naturally | — |
| One-off encapsulated setup | Idiomatic | Heavy | Fine |
| State shared across an app | — | — | Module-level binding |
The prototype-method argument matters at scale: with a closure-based factory, each instance allocates new function objects; with a class, methods live once on Greeter.prototype and instances only carry their own data.
class Greeter {
greet(name) { return `Hello, ${name}!`; }
}
const g = new Greeter();
console.log(g.greet('Ada')); // Hello, Ada!
console.log(Object.getPrototypeOf(g) === Greeter.prototype); // trueRetention and Lifetime
To reason about leaks, ask: what holds a reference to this closure, and what does the closure's environment reference? A closure is collectable only when nothing references it; its environment dies with it. The dangerous patterns are all the same shape — a long-lived holder (global cache, event target, promise chain, module-level array) referencing closures that reference big objects. Mitigations: explicit teardown (removeEventListener, abort signals), weak holders (WeakMap, WeakRef) for cache-like semantics, and copying out only the values a callback actually needs.
Exercises
Exercise
Why does next() keep incrementing count after makeCounter has returned?
Show answer
The returned function carries a reference to makeCounter's heap-allocated environment record. count lives in that record, so it persists and is shared across all calls to next.
Exercise
A button's click handler was registered once at startup and reads config.endpoint from its enclosing scope. The config object is later replaced with a new object. Which endpoint does the handler use, and why?
Show answer
The old one. The handler captured the binding as it existed in the registration scope; reassigning config in that same scope would be visible, but replacing the value in a different scope/module (or mutating a different object) does not affect the handler's captured environment. Re-create the handler or read config indirectly through a stable reference.
Exercise
Your service's memory grows steadily. Profiling shows many retained Context objects rooted at listener registrations on a singleton emitter. Give two remedies.
Show answer
(1) Deregister listeners during teardown (e.g., on shutdown or AbortSignal), and (2) narrow the capture: pass only the values the listener needs rather than closing over a large object, or store shared state in a WeakMap keyed by the emitter.
Spotted a mistake, or code that does not work?