Home / Articles

How V8 closure contexts retain memory beyond what one function uses

V8 builds one shared context per call for every outer variable any inner function mentions—so unused siblings and permanent listeners can pin large values.

Publisher: REACTAPP.TOP8 min read1849 words

This article is published in English.

Sources: Interviewer: You have 5 years of experience and still don’t know how closures work internally

Check it, don’t recite it: [[Scopes]]

A Swiggy frontend interview put a short DOM snippet on the board. The candidate had years of JavaScript experience and answered the first question with the textbook story. The follow-up broke that story the way it breaks for most working developers.

Here is the starting snippet:

function update() {
  const elem = document.getElementById("root");
  const allH1s = document.querySelectorAll("h1");

  function handleClick() {
    console.dir(handleClick);
  }

  elem.addEventListener("click", handleClick);
}

update();

After update() returns, the click listener remains on #root. Does handleClick close over allH1s, keeping the whole NodeList alive?

The textbook reply is yes: a closure retains its lexical environment, so anything in update’s scope—including elem and allH1s—survives while the listener is attached. That answer describes the specification language more than what Chrome actually retains. The interviewer said to open DevTools and look.

Every function object in Chrome exposes an internal [[Scopes]] list of the scopes the closure still holds. console.dir reveals it. The handler logs itself on each click, so you can:

  1. Open a page that has <h1 id="root"> (or create one), paste the snippet, and run it.
  2. Click the heading so console.dir(handleClick) prints the function.
  3. Expand [[Scopes]], then Closure (update).

What appears is not allH1s and not elem. The only outer binding kept is handleClick itself, and only because the body references that name in console.dir(handleClick). Change the body to console.log("clicked") and the Closure (update) entry vanishes; [[Scopes]] shows only Global.

So the NodeList is not held by the handler. Once update() returns, nothing names it and the collector may reclaim it. The inspector view alone is not a memory proof—forced GC later confirms that—but it already falsifies “the whole scope is captured.”

The candidate then switched to the other common model: closures keep only variables they mention, and handleClick never names allH1s. The interviewer asked for one extra line without editing handleClick.

One line that never runs

function update() {
  const elem = document.getElementById("root");
  const allH1s = document.querySelectorAll("h1");
  const printH1 = () => allH1s;   // never called

  function handleClick() {
    console.dir(handleClick);   // unchanged
  }

  elem.addEventListener("click", handleClick);
}

update();

printH1 is an unused arrow that reads allH1s. Nothing calls it, returns it, or stores it. Run the same inspect steps after adding it.

handleClick is unchanged character-for-character, yet it now shows a NodeList it never reads. The arrow itself is absent from [[Scopes]]—nothing captured printH1, so that binding died with the call—but the variable it mentioned survived inside the shared context.

“Whole scope” fails the first case. “Only what this function uses” fails the second. V8’s rule is about the call, not about each function in isolation.

The rule V8 actually follows

When V8 compiles update, it scans every inner function and unions the outer variables any of them reference. That union becomes one heap-allocated context object created once per invocation. Variables no inner function mentions stay on the stack and disappear when update returns. Every closure born during that invocation points at the same context. There is no private “my captures” bag per function.

Apply the rule:

  • Case 1: only handleClick exists among inners; it references handleClick. Context = { handleClick }. elem and allH1s stay stack-local and die.
  • Case 2: inners are printH1 and handleClick. Union includes allH1s and handleClick. Context = { allH1s, handleClick }. Nothing references printH1, so that binding dies—but the context slot it forced is the same object handleClick keeps alive.

Two consequences of that rule are what produce real leaks.

It is decided by reading the code, before anything runs

Context shape is fixed at parse time. The engine does not wait to see whether printH1 is ever constructed. Park it inside an impossible branch and allH1s is still promoted:

if (false) {
  const printH1 = () => allH1s;   // never even instantiated
}
// [[Scopes]] of handleClick: Closure (update) { allH1s: NodeList(3), handleClick }

Running that variant under Chrome 153 via the DevTools protocol and forcing GC afterward still leaves the NodeList alive. Assigning printH1 = null after creation changes nothing: the helper itself was never pinning memory—the shared context slot was.

Retention follows the longest-lived sibling closure

That is why the interviewer’s question was about memory. Trace the pins:

[ <h1 id="root"> ]   (still in the document)
      ↓ event listener list holds
[ handleClick ]   (the function object)
      ↓ [[Context]] points to the ONE context for this call
[ { allH1s, handleClick } ]   (the shared context)
      ↓ allH1s slot holds
[ NodeList(3) [h1#root, h1, h1] ]
      meanwhile
[ printH1 ]   <- gone, it was on the stack

printH1 is gone. The promotion it caused—allH1s living in the context—outlives it. handleClick pins the context; the element’s listener list pins handleClick; the document pins the element. Until the node is removed or the listener detaches, the NodeList and the h1 nodes it references stay reachable.

Prove it is memory, not an inspector artefact

[[Scopes]] shows references. Only garbage collection shows retention. Chrome can force a collection; WeakRef observes an object without keeping it. Paste:

let ref;
function update() {
  const elem = document.getElementById("root");
  const allH1s = document.querySelectorAll("h1");
  ref = new WeakRef(allH1s);            // observe, don't retain
  const printH1 = () => allH1s;         // the one line
  function handleClick() {
    console.dir(handleClick);
  }
  elem.addEventListener("click", handleClick);
}
update();

Open the Memory panel, click Collect garbage a few times, then check the console:

> ref.deref()
▶ NodeList(3) [h1#root, h1, h1]
// still here after two forced GCs

Remove the printH1 line, reload, and repeat:

> ref.deref()
undefined
// collected — nothing was holding it

Those two outcomes differ by one unused arrow: either the NodeList dies when update returns, or it lives as long as the click handler.

DevTools can also pin objects you logged. If you clicked before forcing GC, console.dir(handleClick) ran and the console retained the printed function—and therefore the context—even after a { once: true } listener detached. Clearing the console (or avoiding logging the suspect) before trusting a heap snapshot matters when hunting real leaks.

Where this bites in real code

Three headings are a toy. Substitute a large fetched payload, a parsed workbook, or a million-row array for allH1s. Substitute a debug helper, an unused formatter, or a “keep for later” callback for printH1. Attach a long-lived listener—resize on window, a socket message handler, or a setInterval tick.

function setup() {
  const rows = new Array(1e6).fill(0).map((_, i) => ({ i }));  // the big thing
  const debug = () => rows.length;        // mentions it, never called

  function onResize() {
    console.dir(onResize);              // never touches rows
  }
  window.addEventListener("resize", onResize);
}
setup();

After setup() returns and GC runs, onResize’s [[Scopes]] still shows the million-element array, and a WeakRef on it still resolves. The resize handler pins memory it never names. Reviews miss the pattern because the live function looks innocent and the sibling that caused promotion may have been added much later.

The fix

Each option below was checked the same way: [[Scopes]], forced GC, then WeakRef.deref().

1. Avoid nested functions that name the large outer value

Captures:

const printH1 = () => allH1s;

Does not:

const printH1 = (list) => list;

Passing the value as a parameter avoids an outer reference, so no context slot appears. Deleting unused helpers is even better.

2. Detach the listener when you are done

The context survives only while handleClick does. Break that link:

// any one of these frees the NodeList on the next GC
elem.removeEventListener("click", handleClick);
elem.addEventListener("click", handleClick, { once: true });
elem.addEventListener("click", handleClick, { signal: controller.signal });
controller.abort();

Removing #root from the document also frees it. This class of leak prefers durable targets such as window, document, sockets, and long-lived roots, because nothing routinely removes the listener.

3. When a sibling must keep access, null the outer binding afterward

let allH1s = document.querySelectorAll("h1");
const printH1 = () => allH1s;
// ...use it...
allH1s = null;   // the context slot still exists, but now holds null

After nulling, [[Scopes]] still lists allH1s, but the value is null and the NodeList can be collected. It is the least elegant option, yet useful when restructuring the sibling is not possible today.

Is this the spec, or V8?

V8. ECMAScript says a closure captures its Environment Record and is silent about GC strategy. A conforming engine could retain every in-scope binding for the life of any closure; another could be stricter than V8. “Only variables some inner function mentions” is V8’s optimization. That is why “the whole scope” matches the wording of the spec and not what Chrome, Edge, Node, or Deno retain in practice.

Do not depend on either behavior. Keep large values in narrow scopes, write helpers that take arguments instead of closing over outers, and give every long-lived listener a documented detach point.

What the interviewer was testing

Not the dictionary definition of a closure. What mattered was recognizing that capture is scoped to an invocation, not to each nested function: siblings from one call share a single context, that context’s layout is fixed during parsing, retention follows the longest-lived sibling, and a working engineer opens DevTools instead of quoting a diagram.

Key takeaways

  • Closures neither keep entire scopes nor only their own referenced names. They share one context per call holding every outer variable any inner function mentions.
  • That set is chosen at parse time. Dead-code inners and nulled helpers still promote the names they mention.
  • The context lasts as long as its longest-lived closure. A permanent listener makes the whole context permanent.
  • Confirm references with console.dir(fn) → [[Scopes]], and retention with WeakRef plus Memory → Collect garbage. Clear the console first so DevTools is not the retainer.

Interview loops reward people who can connect engine behavior to leak triage. Being able to narrate Environment Records is less useful than knowing which panel to open, which icon forces a collection, and how console logging itself can keep a suspect graph alive. Treat [[Scopes]] as a map of references and WeakRef plus forced GC as the proof of retention, then redesign so the answer stops depending on optimizer details.