Skip to content

3.6.2 — Closures In Depth

3.6.1 ended with a promise: that lexical scoping — names resolved by where code is written — would pay off spectacularly. This page is the payoff. A closure is a function that remembers the variables of the place it was born, and keeps access to them for as long as it lives, no matter where or when it is later called. It is simultaneously the most-asked JavaScript interview topic, the mechanism behind half the patterns you use daily (private state, factories, memoization, debouncing, React hooks), and a leading cause of production memory leaks. By the end of this page you will be able to predict every closure behavior from one mental model, rather than memorizing cases. Explain closures. [EQ-565]

Recall from 3.6.1 that every scope gets an environment — a bookkeeping structure holding its variables — and every environment holds a reference to its outer environment. The single fact that makes closures work is this:

When a function is created, it stores a hidden, permanent reference to the environment it was created inside. The specification calls this internal slot [[Environment]]. When the function is later called, its new scope's outer link is set to that stored environment — not to the caller's environment.

So a function is never just code. A function is code plus a captured environment. Let's watch the mechanism run, line by line:

javascript
function makeCounter() {
  let count = 0;                    // (1) lives in makeCounter's environment
  return function increment() {     // (2) increment is BORN here — it stores
    count++;                        //     a [[Environment]] link to (1)'s scope
    return count;
  };
}

const next = makeCounter();          // (3) makeCounter runs… and RETURNS
next();  // → 1                      // (4) yet count is still alive
next();  // → 2                      // (5) …and remembers its value between calls

Step by step:

  1. Calling makeCounter() creates a fresh environment; count is stored in it.
  2. The function increment expression creates a function object whose [[Environment]] points at that environment. Nothing is copied — it's a live link.
  3. makeCounter returns. Its stack frame pops (3.6.1). In most languages that would be the end of count.
  4. But next (our returned function) still holds [[Environment]] → the environment holding count. The environment is reachable, so the garbage collector (3.4) must keep it — it migrates, in effect, from stack to heap.
  5. Every call to next() reopens that same environment and mutates the same count.
next (function object)code: count++; return count[[Environment]] ─┐makeCounter's environmentcount: 2outer → global environmentlive linkGarbage collector's viewnext is reachable → its [[Environment]] is reachable → count CANNOT be freed
Figure 1 — A closure is a function plus a live link. The returned function object carries a hidden [[Environment]] reference to the scope it was born in. As long as the function is reachable, so is that environment — which is exactly why count outlives makeCounter.

Two immediate consequences, both testable in an interview:

Each call to the factory creates a fresh environment. Closures from different calls are fully independent:

javascript
const a = makeCounter();
const b = makeCounter();   // a brand-new environment, a brand-new count
a(); a();  // → 1, 2
b();       // → 1   ← b's count is its own; a's is untouched

Sibling closures born in the same call share one environment. Capture is per-scope, not per-function:

javascript
function makeAccount(balance) {
  return {
    deposit:  (amt) => balance += amt,   // both arrows captured the SAME
    withdraw: (amt) => balance -= amt,   // environment holding `balance`
    check:    ()    => balance
  };
}
const acc = makeAccount(100);
acc.deposit(50);
acc.check();   // → 150 — deposit's mutation is visible to check

deposit, withdraw, and check are three closures over one shared balance. This is how closures give you a cluster of functions with shared private state — an object, in everything but syntax.

2. The rule that explains everything: capture by variable, not by value

The most common closure misconception is that a closure "snapshots" the value at creation time. It does not. A closure captures the variable itself — the storage slot — not the value in it. Reads happen at call time, seeing the slot's current contents:

javascript
let greeting = "hello";
const speak = () => console.log(greeting);  // captures the SLOT `greeting`

greeting = "goodbye";                        // mutate after capture
speak();   // → "goodbye"  — NOT "hello"; the closure reads the live slot

Hold that rule — capture the slot, read at call time — and every "tricky" closure question becomes mechanical. Including the most famous one in JavaScript.

3. The loop trap: var vs let, finally explained properly

Every JavaScript interviewer has asked this. Predict the output:

javascript
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 10);   // [!code error] // → 3, 3, 3
}

Not 0, 1, 2 — it prints 3, 3, 3. Apply the model: var is function-scoped (3.6.1), so there is exactly one variable i for the whole loop, living in the surrounding function's environment. All three arrow callbacks capture that same slot. The loop finishes (leaving i = 3) before any timer fires (3.6.8 explains why timers always wait for the current code to finish). When the callbacks finally run, each reads the shared slot → 3, three times.

Change one keyword and it works:

javascript
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 10);   // [!code highlight] // → 0, 1, 2
}

Why does let fix it? Because the specification gives a let loop a brand-new environment per iteration, copying the loop variable's current value into it. Three iterations → three separate slots holding 0, 1, 2 → three closures each capturing their own slot. The rule didn't change — capture-the-slot still holds — the number of slots changed.

Before let existed (pre-2015), the fix was to manufacture a fresh scope per iteration by calling a function — an IIFE (Immediately Invoked Function Expression — a function expression called the moment it's defined):

javascript
for (var i = 0; i < 3; i++) {
  (function (captured) {                   // ← a new function scope per iteration
    setTimeout(() => console.log(captured), 10);   // → 0, 1, 2
  })(i);                                   // ← pass in the CURRENT value of i
}

Each call creates a new environment with its own captured parameter holding a copy of i's value at that moment. You will still meet this pattern in older codebases; recognizing it as "manual per-iteration scope" tells you exactly why it exists.

4. The pattern catalog — what closures are for

Closures are not a quiz curiosity; they are the machinery behind the everyday toolkit. Each pattern below is a real idiom you will write or read this month.

4.1 Private state (information hiding)

count and balance above cannot be read, written, or corrupted from outside — the only access path is the functions that captured them. Before class fields got # privacy (3.6.4), closures were JavaScript's only true privacy mechanism, and they remain the foundation of the module pattern (3.6.5). Note the contrast with a plain object property: user.balance is open to the world; a closed-over balance is invisible even to JSON.stringify, debuggers' casual inspection, and Object.keys.

4.2 Function factories & configuration

A factory captures its configuration once, then stamps out a specialized function:

javascript
function makeUrlBuilder(baseUrl) {          // configuration captured once…
  return (path) => `${baseUrl}${path}`;     // …used forever after
}

const api    = makeUrlBuilder("https://api.example.com");
const cdn    = makeUrlBuilder("https://cdn.example.com");
api("/users/42");   // → "https://api.example.com/users/42"
cdn("/logo.png");   // → "https://cdn.example.com/logo.png"

This is dependency injection in its smallest form: instead of every call site passing baseUrl, the knowledge is baked in at creation. Real-world sightings: configured loggers (makeLogger("auth-service")), API clients carrying tokens, and every React custom hook returning callbacks.

4.3 Memoization — trading memory for speed

Cache results of a pure function; the cache lives in a closure, invisible and incorruptible:

javascript
function memoize(fn) {
  const cache = new Map();                  // private — only the wrapper sees it
  return function (arg) {
    if (cache.has(arg)) return cache.get(arg);   // hit: skip the work entirely
    const result = fn(arg);                       // miss: compute once…
    cache.set(arg, result);                       // …remember forever
    return result;
  };
}

const slowSquare = (n) => { /* imagine heavy work */ return n * n; };
const fastSquare = memoize(slowSquare);
fastSquare(9);   // computes → 81
fastSquare(9);   // instant  → 81 (from cache)

The essential closure property here: cache persists between calls (it's in the captured environment, not the call's stack frame) yet is completely private. This exact shape scales up to React.useMemo, Python's functools.lru_cache, and the dynamic-programming memoization of Chapter 4.22.

4.4 once — run at most one time

javascript
function once(fn) {
  let done = false, result;                 // the "has it run?" flag is private
  return function (...args) {
    if (!done) { done = true; result = fn(...args); }
    return result;                          // later calls: same result, no re-run
  };
}

const init = once(() => console.log("initializing…"));
init();   // → "initializing…"
init();   // (silence — the closure remembers it already ran)

Used for one-time initialization, "pay exactly once" listeners, and idempotent setup — the tiny sibling of the idempotency ideas in Chapter 10.4.

4.5 Currying & partial application

Currying transforms a function of several arguments into a chain of single-argument functions, each closure carrying the arguments gathered so far:

javascript
const add = (a) => (b) => (c) => a + b + c;
//           └── returns a function that CAPTURED a
//                     └── returns a function that captured a AND b
add(1)(2)(3);        // → 6

const add1     = add(1);      // a=1 locked in
const add1and2 = add1(2);     // b=2 locked in
add1and2(3);                  // → 6

Partial application is the looser everyday version — fix some arguments now, supply the rest later (makeUrlBuilder above is exactly this). Functional libraries and the pipeline style of 3.5 lean on it heavily.

4.6 Debounce — closures taming event streams

The most practically valuable closure pattern in frontend work. A search box fires an event per keystroke; you want one API call after the user stops typing:

javascript
function debounce(fn, delayMs) {
  let timerId;                              // persists across ALL events
  return function (...args) {
    clearTimeout(timerId);                  // each new event cancels the pending one
    timerId = setTimeout(() => fn(...args), delayMs);  // and re-arms the clock
  };
}

const search = debounce((text) => fetchResults(text), 300);
// typing "cat" quickly → only ONE fetchResults("cat"), 300 ms after the last key

timerId must survive between events and be private to this debounced function — precisely the two things a closure provides. Its sibling throttle ("at most once per N ms") uses the same skeleton with a timestamp instead of a timer. Both reappear in Chapter 6.3.

4.7 Callbacks that carry context

Every asynchronous continuation — event handlers, timers, promise .then, await resumption — relies on closures to remember what it was doing:

javascript
function loadUser(userId) {
  fetch(`/api/users/${userId}`)             // userId is needed…
    .then((res) => res.json())
    .then((user) => {
      console.log(`loaded ${userId}:`, user.name);   // …long after loadUser returned
    });
}

By the time the network responds, loadUser is long gone from the stack — yet the callback still knows userId. Without closures, every async API would need you to manually pack and pass a "context object" through (which is exactly what C forces with its void *userdata parameters). Closures are why JavaScript's async style feels effortless.

5. Stale closures — the modern trap (React and friends)

The capture-the-slot rule has a sharp edge in the other direction: capture a slot that gets replaced (rather than mutated), and your closure goes on reading the old slot — a stale closure. This is today's most common real-world closure bug, and it's the one modern frameworks make easy to hit:

javascript
function Timer() {
  const [count, setCount] = useState(0);          // React: count is a NEW const
                                                  // on every render
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);      // [!code error] // captured THIS render's count — forever 0
    }, 1000);
    return () => clearInterval(id);
  }, []);                       // [!code error] // empty deps: effect runs once, closure never refreshed
  // Result: displays 1 and stops — every tick computes 0 + 1
}

Trace it with the model: the effect runs once, on the first render, so the interval callback captures the first render's count const, whose value is 0. React never mutates that binding — each re-render creates a new count in a new environment — but the old closure still points at the old one. Every tick computes setCount(0 + 1). The display sticks at 1.

Two idiomatic fixes, both of which you can now derive rather than memorize:

javascript
setCount((current) => current + 1);   // [!code highlight] // (a) functional update: ask React for
                                      // the CURRENT value instead of reading a captured one
javascript
useEffect(() => { /* …same interval… */ }, [count]);  // [!code highlight] // (b) declare the dependency:
// re-run the effect per change, so a FRESH closure captures the fresh count

The general principle beyond React: a closure is only as fresh as the environment it captured. Whenever a framework re-creates bindings (new render, new request, new iteration) instead of mutating them, a long-lived closure from an old cycle is reading history. If you see an async callback using "impossibly old" data — this is why.

6. The memory model: what a closure really retains

Closures keep environments alive — which makes them a first-class memory-management concern (3.4).

What is captured? Conceptually, the whole birth scope. In practice, V8 optimizes: variables that no inner function references are not stored in the heap-allocated context object at all — they stay on the stack and die normally. But V8 allocates one context object per scope, shared by all closures born there. The subtle consequence: if any closure from a scope captures a large object, every closure from that scope retains it, even ones that never mention it — the retained set is per-scope, not per-closure. (Engine behavior, so treat the details as illustrative — but the per-scope sharing is real and shows up in heap snapshots.)

javascript
function handler() {
  const hugeData = loadHundredMegabytes();     // referenced by shortLived…
  const shortLived = () => hugeData.length;
  const longLived  = () => console.log("hi");  // …never uses hugeData
  window.addEventListener("resize", longLived);
  return shortLived;
}
// Even after shortLived is dropped, longLived — alive forever via the
// listener — shares the scope's context object, and hugeData may be retained.

The leak recipe is always the same three ingredients: a closure that captured something big, registered with something long-lived (global emitter, setInterval, a cache, a singleton store), and never de-registered. The cure is symmetric teardown — removeEventListener, clearInterval, unsubscribe, AbortController — plus the habit of capturing narrowly:

javascript
// Instead of closing over the whole 100 MB object…
const size = hugeData.length;                    // extract the 8 bytes you need
element.addEventListener("click", () => report(size));  // capture only `size`

Debugging it: DevTools → Memory → heap snapshot; leaked environments appear as context entries in retainer chains — "what still points at this?" walks you straight from the leaked object, through the context object, to the listener that should have been removed. The full workflow lives in the drill below and in Chapter 14.5.

7. The expert lens

One model, every behavior. Function creation stores [[Environment]]; calls read through it, live, at call time; environments are per-scope and heap-lived while reachable. From those three clauses you can derive the loop trap (var = one slot), its let fix (per-iteration slots), sibling sharing (one scope, many closures), stale closures (old environment, new bindings elsewhere), and the leak model (retention is per-scope reachability). Interviewers escalate closure questions in exactly this order; deriving beats memorizing at every step.

Closures and objects are duals. A closure is state hidden behind functions; an object is functions attached to visible state — "a poor man's object" and "a poor man's closure," as the old koan has it. JavaScript lets you pick per situation: closures when privacy and capture matter (hooks, factories, callbacks), objects/classes when identity, enumeration, and shared methods matter (3.6.4 shows why methods on prototypes are cheaper than per-instance closures at scale — one function object versus one per instance).

Language design ripples. Closures only work because JavaScript chose lexical scoping and first-class functions in 1995 — two decisions that later made callbacks, promises, and the entire async ecosystem (3.6.8) pleasant to use. Languages that lacked real closures retrofitted them (Java's lambdas capture only effectively-final variables — value capture, not slot capture, dodging the loop trap at the cost of expressiveness; Python closes over variables but its late-binding loops reproduce the var trap exactly). When you meet a new language, "what exactly does a lambda capture, and when is it read?" is one of the five questions that predicts most of its behavior.

Next: the other famously misunderstood binding — 3.6.3 gives this the same treatment: one rule set, every case derivable.

Recall

  • A function object stores a permanent [[Environment]] link to the scope it was created in; calling it chains lookups through that link. A closure = code + captured environment, kept alive by reachability, migrating variables from stack to heap.
  • Capture is by slot, not by value; reads happen at call time. One scope → one environment shared by all closures born there; a new call → a fresh, independent environment.
  • The loop trap: var = one shared slot → 3,3,3; let = a per-iteration environment → 0,1,2; the historical IIFE fix manufactured per-iteration scopes manually.
  • The pattern catalog: private state, factories/configuration, memoization, once, currying/partial application, debounce/throttle, and every async callback that "remembers" its context.
  • A stale closure reads an old environment after frameworks re-create bindings (the React useState/useEffect trap; fix with functional updates or correct dependencies). Leaks = big capture + long-lived registration + no teardown; V8 retention is per-scope, so capture narrowly and always de-register.

Self-test: What exactly does [[Environment]] point to, and when is it set? Why does the var loop print 3,3,3 and what precisely does let change? Why can two methods returned from one factory see each other's mutations? Derive the React stale-interval bug from the capture rule. Name the three ingredients of a closure memory leak and the matching cures.

Quiz Bank

FoundationalWhat is a closure, mechanically — not as a slogan?

A closure is a function object plus the environment it was created in. At creation, the function stores an internal [[Environment]] reference to its birth scope; at every later call, name lookups that miss the function's own scope continue through that stored link — not through the caller's scope (lexical scoping). Because the function holds a live reference, the environment remains reachable, so the garbage collector keeps it alive after the outer function returns — the captured variables effectively move from stack to heap and persist between calls. That is the entire mechanism; every closure behavior derives from it.

FoundationalDoes a closure capture the value of a variable or the variable itself?

The variable itself — the storage slot. Nothing is copied at creation time; reads and writes go through the live link at call time and see the slot's current contents. Demonstration: capture greeting while it holds "hello", reassign to "goodbye", call the closure — it prints "goodbye". This one rule explains the loop trap (all callbacks share var's single slot), sibling closures seeing each other's mutations (same scope, same slots), and stale closures (a closure keeps reading an old slot after the framework created a new one elsewhere).

InterviewWhy does a setTimeout inside a var loop print 3,3,3, and why does changing var to let print 0,1,2?

var is function-scoped, so the whole loop shares exactly one variable i in the enclosing function's environment; all three callbacks capture that same slot. The loop completes (leaving i = 3) before any timer callback can run — timers wait for the current synchronous code to finish (3.6.8) — so each callback then reads the shared slot and prints 3. With let, the specification creates a fresh environment per iteration holding that iteration's value of i, so the three closures capture three different slots containing 0, 1, 2. The capture rule never changed — only the number of slots did. (Pre-ES6 fix: wrap the body in an IIFE and pass i as a parameter, manufacturing a per-iteration scope manually.)

AppliedWrite debounce and explain which parts depend on closure behavior.
javascript
function debounce(fn, delayMs) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), delayMs);
  };
}

Three closure dependencies: (1) timerId must persist across calls of the returned function — it lives in debounce's captured environment, not in any call's stack frame; (2) it must be private — no other code can cancel or corrupt the timer; (3) the inner arrow () => fn(...args) itself closes over fn and args, remembering which function to run with which arguments when the delay elapses. Each debounce(...) call creates an independent environment, so multiple debounced functions never interfere. This is the standard pattern for search boxes, resize handlers, and autosave.

InterviewWhat is a stale closure? Give the classic React example and both fixes.

A stale closure is a closure reading an outdated environment after the surrounding system re-created bindings rather than mutating them. Classic case: useEffect(() => { setInterval(() => setCount(count + 1), 1000) }, []). The effect runs once, so the interval callback captures the first render's count (value 0). React never mutates that binding — each render makes a new count in a new environment — so the old closure computes 0 + 1 forever and the UI sticks at 1. Fixes: (a) functional update setCount(c => c + 1) — asks React for the current value instead of reading a captured slot; (b) declare [count] as a dependency so each change re-runs the effect and a fresh closure captures the fresh binding. General rule: a closure is only as fresh as the environment it captured.

StaffHow do closures cause memory leaks, what does V8 actually retain, and how do you find one in production?

Recipe: a closure that (1) captured something large, (2) is registered with something long-lived — a global emitter, setInterval, a module-level cache, a store subscription — and (3) is never de-registered. The registration holds the closure; the closure holds its environment; the environment holds the payload — all reachable, so the GC (3.4) can never free it.

V8 detail: variables no inner function uses aren't put in the heap context at all, but V8 allocates one context object per scope shared by all closures born there — so if any sibling closure captures a large object, every closure from that scope can retain it, even ones that never reference it (capture narrowly: extract the small field you need into its own variable and close over that).

Finding it: DevTools → Memory → take heap snapshots across the suspected cycle (e.g. several view navigations); growing counts of detached DOM nodes or view objects flag the leak; the retainer chain then reads mechanically: leaked object ← context ← listener ← emitter — telling you exactly which teardown (removeEventListener, clearInterval, unsubscribe, AbortController.abort) is missing. Symmetric setup/teardown is the cure; every long-lived registration is a memory-retention decision.

StaffClosures versus objects — when is each the right tool, and what is the per-instance cost difference?

They are duals: a closure hides state behind functions; an object exposes state carrying functions. Choose closures for capture and privacy — callbacks that must remember context, factories baking in configuration, genuinely private state (pre-# fields, closures were JavaScript's only privacy), one-off behaviors (debounce, once, memoize). Choose objects/classes for identity, many instances, enumeration/serialization, and shared behavior — because methods live once on the prototype (3.6.4) and are shared by every instance, whereas a closure-based "instance" allocates a fresh function object per method per instance plus a context object. For thousands of instances that difference is real memory and hidden-class friendly (3.6.9) versus not. Hybrid rule of thumb: classes for the domain model, closures at the edges (handlers, configuration, async glue).

Flashcards

Flash[[Environment]]

Hidden slot set at function creation, pointing to the birth scope. Calls resolve free variables through it — the entire closure mechanism.

FlashCapture rule

By slot, not value; read at call time. New factory call → new environment. Same scope → all its closures share one environment.

Flashvar loop vs let loop

var: one shared slot → 3,3,3. let: fresh environment per iteration → 0,1,2. Old fix: IIFE passing i as a parameter.

FlashStale closure

Closure reading an old environment after bindings were re-created (not mutated). React fix: functional updates or honest dependency arrays.

FlashClosure leak recipe

Big capture + long-lived registration + no teardown. Cure: symmetric de-registration and narrow capture (extract just the field you need).

FlashV8 context sharing

One heap context object per scope, shared by all closures born there — any sibling's big capture can be retained for all of them.

Scenario Drill

DrillA dashboard polls an API every 5 seconds and renders the result. Users report that after editing the refresh interval in settings, the dashboard still polls at the old rate AND memory climbs the longer the tab stays open. Diagnose both symptoms with closure reasoning and fix the code.

The two symptoms are the two closure hazards of this page, and they share one root: the polling callback is a closure over a dead configuration scope, registered with a long-lived timer that nobody tears down. Symptom 1 (old rate): the code almost certainly did setInterval(poll, settings.intervalMs) once at startup; when settings change, the app creates a new settings object/binding, but the running interval was armed with the old number (and any closure reading settings from the startup scope is stale — capture is by slot, and that slot belongs to the old environment). Editing settings therefore changes nothing the timer can see. Symptom 2 (memory climb): each re-render or settings edit likely calls the setup again — setInterval returns a new id each time, and if the old id isn't cleared, every generation of the polling closure stays registered and reachable: closure → environment → response data/DOM references, none collectible (3.4). Heap snapshots would show one extra context retainer chain per edit, each hanging off an interval. Fix — symmetric lifecycle with fresh capture:

javascript
function startPolling(intervalMs, onData) {
  const controller = new AbortController();
  const id = setInterval(async () => {
    const res = await fetch("/api/stats", { signal: controller.signal });
    onData(await res.json());
  }, intervalMs);
  return () => { clearInterval(id); controller.abort(); };  // teardown, always returned
}

let stop = startPolling(settings.intervalMs, render);
onSettingsChange((next) => {
  stop();                                   // tear down the old closure generation
  stop = startPolling(next.intervalMs, render);  // fresh closure over fresh config
});

Every generation now (1) captures the interval as a parameter — a fresh slot per start, no stale reads; (2) returns its own teardown, and the settings handler calls it before re-arming — so exactly one timer and one closure environment are ever reachable. In React the identical shape is useEffect(() => { …; return stop; }, [settings.intervalMs]) — the dependency re-runs the effect (fresh closure) and the cleanup return prevents accumulation. State the principle at the end: long-lived registrations must own a teardown, and re-configuration means tearing down the old closure, never hoping it will notice new data.