cswork / libraryOpen practice workspace

Articles

JavaScript Closure Interview Questions: Stale State, Retained Memory, and Loop Captures

Practice JavaScript closure interview questions on stale state, var and let loop captures, retained memory, cleanup ownership, and heap evidence.

JavaScript closure interview questions usually hide two different problems inside one callback. The first is a correctness problem: which binding will the function read when it eventually runs? The second is a lifetime problem: which objects remain reachable because something still holds that function?

Solve them with two diagrams. Draw a binding timeline for output and stale-state questions. Draw a root-to-object retaining path for memory questions. A closure does not simply “copy every outer value,” and a closure is not automatically a leak.

Start with the exact closure contract

Official fact: MDN defines a closure as a function bundled with references to its surrounding lexical environment. JavaScript creates that relationship when the function is created. The function can later access an outer binding even after the outer function has returned.

The careful word is binding. A binding is a named slot in an environment. Reading it later can produce a newer value. But code can also compute a scalar now and close over that separate snapshot. Interview bugs often come from confusing those two cases.

let count = 0;
const live = () => count;
const snapshot = count;
const frozen = () => snapshot;

count = 1;
console.log(live(), frozen()); // 1, 0

Both functions are closures. live reads the count binding when called. frozen reads a different binding named snapshot, whose value was calculated earlier. Saying “closures capture by reference” points in the right direction but still does not explain which binding the code created.

Question 1: Why do var callbacks all print the same number?

Consider the classic loop:

const callbacks = [];
for (var i = 0; i < 3; i++) {
  callbacks.push(() => i);
}
console.log(callbacks.map(fn => fn())); // [3, 3, 3]

var is function-scoped here. The loop creates one i binding, every callback closes over that same binding, and the loop leaves it at 3. The callbacks run afterward and all read 3. The essential cause is shared binding identity, not merely that the callbacks are asynchronous; this example produces the same result when the array is invoked synchronously after the loop.

Now change var to let. The for statement creates a new per-iteration binding, so the callbacks read 0, 1, and 2. const also works when each iteration binds an item that does not need reassignment.

VersionEnvironments createdValue read laterOutput
var iOne function-scoped iFinal shared value3, 3, 3
let iNew i per iterationEach iteration's value0, 1, 2
IIFE parameterOne call environment per iterationArgument passed that turn0, 1, 2
forEach(item => …)One callback call parameter per itemThat invocation's itemItem sequence

This table is an editorial model of the documented behavior. In an interview, predict the output first, then name the environment that owns each binding.

Question 2: What makes a callback read stale state?

“Stale closure” is common frontend shorthand, but the closure mechanism is working correctly. The callback is reading exactly the environment it was created with. The design bug is that the program needed current state but preserved an older snapshot or kept an obsolete callback registered.

function makeSubmitter(initialDraft, send) {
  const body = initialDraft.text;
  return () => send(body);
}

If the user edits initialDraft.text, this submitter still sends the earlier body scalar. That can be deliberate: perhaps a queued submission must preserve what the user approved. If the requirement is “send the latest draft,” the function needs a current-state source instead.

function makeSubmitter(getDraft, send) {
  return () => send(getDraft().text);
}

This moves freshness into the getDraft contract. It does not make every race disappear. The draft could change after the read, and an asynchronous response could still arrive after a newer request. State which moment owns the value: registration, invocation, request start, or response commit.

Trace freshness with a four-moment timeline

Suppose a search callback is registered at version 7, the user types again at version 8, the old callback fires, and its request returns last. Ask four questions:

  1. Which version did the callback capture when registered?
  2. Which state did it read when invoked?
  3. Which version did the request carry when it started?
  4. Is that version still allowed to update the UI when the response arrives?

A current getter can solve question 2 but not question 4. For response ownership, include a request ID or version and compare it before committing. Cancellation can reduce wasted work, while the version check protects state if cancellation is unsupported or arrives too late.

This is why the article does not reduce stale state to one framework hook. The JavaScript mechanics apply to event listeners, timers, streams, framework effects, and callbacks stored in libraries. Framework APIs add lifecycle rules, but the binding and ownership questions remain.

Question 3: Does a closure copy an entire outer scope?

Avoid making an engine-layout promise. The observable language rule is that the closure can access the necessary lexical environment. Engines may optimize representation as long as behavior stays correct.

For reasoning, draw only the paths your function uses. If a handler reads model.currentUser.profile, show the handler's environment referencing model, then the current object graph. If it instead closes over const profile = model.currentUser.profile, show the environment holding that profile object even if model.currentUser later points elsewhere.

Object properties add another distinction. A const binding cannot be reassigned, but the object it points to can still mutate. const settings = { theme: "light" } followed by settings.theme = "dark" means a closure reading settings.theme sees dark. const does not freeze the object.

Question 4: When does a closure retain memory?

Official memory model: modern JavaScript garbage collection is based on reachability. Starting from roots such as the global object and active host references, the collector follows references and can reclaim objects that are unreachable. Cycles alone are not a leak when the entire cycle is unreachable.

A closure matters when a reachable owner retains the function, the function's environment retains an object, and the object is no longer useful. Consider an event listener:

function mountPanel(button, model) {
  const onClick = () => renderDetails(model.largeIndex);
  button.addEventListener("click", onClick);
  return () => button.removeEventListener("click", onClick);
}

While button and its listener registration remain reachable, the handler and the referenced model may remain reachable too. The returned disposer expresses ownership. Calling it removes this particular registration; any other listener, timer, cache, global array, or debugger reference can still retain the same model.

The stronger interview answer is a path: Window → button → listener → closure environment → model → largeIndex. Then identify the edge that teardown should remove. “Set everything to null” is not a diagnosis.

Separate retained size from allocation rate

Allocating many closures can create pressure without creating a leak. If the callbacks become unreachable after each operation, memory can return to a stable range after garbage collection. A leak is continuing unintended reachability, observed across equivalent checkpoints.

Chrome DevTools defines shallow size as memory held by an object itself and retained size as the memory that could become reclaimable with that object and its dependent graph. A small listener function can therefore have a large retained size when it keeps a model or detached subtree alive.

Use an allocation timeline to locate repeated allocations, then heap snapshots and retaining paths to find why survivors remain. Clear the console and avoid leaving objects selected in DevTools because debugging references can change what stays reachable.

Diagnose the retaining path, not the largest object

A practical investigation repeats one workflow: open a panel, load data, close it, and return to the same idle state. Capture a baseline, repeat several cycles, allow comparable cleanup, and capture another snapshot. Look for growing counts and retained sizes rather than one high heap reading.

If detached DOM nodes grow, select one and inspect its retaining path. The visible symptom may be a removed element, but the owning edge could be a listener registry, a closure in a timer, a subscription, or an application cache. Remove or bound that owner, repeat the same workflow, and confirm the growth plateaus.

Do not claim one snapshot proves the fix. Garbage collection timing is nondeterministic, caches may warm to a legitimate bound, and browser processes include memory outside the JavaScript heap. Equivalent checkpoints and repeated behavior make the evidence useful.

Question 5: Should you replace Map with WeakMap?

WeakMap can help when object-key lifetime should control associated metadata lifetime. Its keys are held weakly and it is not enumerable, which prevents code from observing garbage-collection timing through a key list. It is not a universal closure repair.

If a global listener still strongly retains the key object through a closure, the WeakMap entry remains reachable through that key. If the cache needs string keys, iteration, exact size, or deterministic eviction, WeakMap does not provide those contracts. First remove the unintended strong path; then choose a collection that matches ownership.

Avoid WeakRef as a default answer. MDN warns that garbage-collection behavior is largely unguaranteed. Correct application behavior should not depend on an object disappearing on a predictable schedule.

Test closure behavior without testing garbage collection

The executable fixture for this article verifies four language-level behaviors: shared var binding, per-iteration let binding, stale snapshot versus live lookup, and an explicit release() that clears the closure-held reference. Those assertions are deterministic.

They do not prove when a JavaScript engine will collect an object. A unit test should verify that teardown removes the listener, cancels the timer, unsubscribes, clears the registry entry, or drops the owned reference. Use browser memory tooling for the separate claim that repeated user workflows no longer retain a growing graph.

This proof boundary is valuable in interviews: code tests ownership actions; heap evidence tests reachability outcomes.

Common answers that need correction

“A closure remembers the value” is incomplete. Identify the binding and when a separate snapshot was created. “Use let because it is asynchronous” misses the per-iteration environment. let fixes the classic loop even if callbacks run later for reasons unrelated to timers.

“Closures cause memory leaks” is also too broad. Reachable registrations and collections determine lifetime. A closure can intentionally retain private state; that is a feature until the owner should release it.

Finally, memoizing with a closure is not free. Define the key, invalidation rule, and bound. An unbounded cache that remains reachable is an ownership decision, even if every entry is technically accessible.

Prepare for JavaScript closure interview questions

Use two diagrams and keep their claims separate. The binding timeline explains what a callback reads. The retaining path explains why an object remains alive. From there, loop captures, stale UI state, listeners, memoization, and memory diagnostics become concrete ownership questions.

Sources and Further Reading

Research note: This article was checked on September 14, 2026. Language and tooling behavior comes from the linked official documentation.