open atlas
↑ Back to track
JavaScript Engine internals JSE · 05 · 02

What a closure actually retains

A closure is a function plus a pointer to its Context. That Context keeps every context-allocated variable alive — including ones the closure never reads — because sibling closures share one Context. The canonical closure leak, and how to break it.

JSE Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

Your server’s heap climbs 40 MB an hour. The profiler points at a tiny event handler — three lines, no allocations, captures nothing big. You read it ten times and it looks innocent. The leak is not in that closure; it is in a sibling function created in the same scope, and a 50 MB buffer that the closure has no idea it is holding hostage. Two functions, one Context, one retained world.

A closure is a function plus a Context pointer

When you write a function that captures an outer variable, you’re not paying for anything magic — you’re just handing the function a pointer it will follow every time it needs that variable. In V8 a function object (a JSFunction) carries two things relevant here: a pointer to its SharedFunctionInfo (the code and ScopeInfo, shared by all instances of that function) and a pointer to its Context — the runtime scope object it was created in. That second pointer is the closure. “Closure” is not a special kind of object; it is the ordinary fact that the function holds a live reference to its defining Context.

Because the function holds the Context, the Context is reachable as long as the function is reachable. And the garbage collector frees only unreachable objects. So the lifetime of every variable stored in that Context is tied to the lifetime of the closure — even variables the closure’s body never mentions.

The trap: siblings share one Context

Here is the part that surprises people. The compiler does not create a separate Context per closure. All functions defined in the same scope capture the same Context object — the one for that scope. If two inner functions live in one scope and the scope has context-allocated variables a and b, then both functions point at the same Context holding both a and b. Keeping either function alive keeps a and b alive.

function makeHandlers() {
  const big = new Array(5_000_000).fill(0); // ~40 MB, context-allocated
  const small = () => 42;                    // captures the SAME Context as below
  const reportSize = () => big.length;       // this one actually uses 'big'
  return small;                              // we return only 'small'...
}
const keep = makeHandlers();
// 'big' is STILL alive: 'small' shares the Context that holds 'big',
// because 'reportSize' (defined in the same scope) forced 'big' to be
// context-allocated. 'small' never reads 'big' — and pins it anyway.

We returned small, a function that computes 42. Yet big cannot be collected. Why? reportSize references big, so the compiler marks big context-allocated and places it in the scope’s one Context. small captures that very Context. As long as keep (the returned small) is reachable, its Context is reachable, and big inside it is reachable. The 40 MB stays.

Reading the retainer in DevTools

This is exactly what a heap snapshot shows. Take a snapshot in Chrome DevTools (or node --inspect), find the big array, and look at its Retainers path. You will see it retained by a system / Context object, which is retained by the closure function. The Context is the smoking gun: when an object’s retainer is a Context you didn’t expect, a closure is holding the scope.

Closure retention, in numbers
Contexts per scope (not per closure)
1
What a closure pins
the whole Context
Variables a closure can pin it never reads
all captured siblings
Retainer chain shown in DevTools
obj → Context → fn
JSFunction shares across instances
SharedFunctionInfo
Cost of one stray long-lived handler
entire scope's heap

Three ways to break the retention

The fix is always to sever the path closure → Context → big:

  1. Null it out. After the last real use, assign big = null. The Context slot now holds null; the 40 MB becomes unreachable and collectable, even though the Context (and small) live on.
  2. Don’t context-allocate it. Move big into a narrower scope (e.g. an IIFE or a separate function) so it is not in the Context that the long-lived closure captures. If no surviving closure captures big, it dies with its frame.
  3. Separate the scopes. Create the long-lived closure in a scope that simply does not contain the big variable. Build small in one function and big in another; they then capture different Contexts.

All three cuts work on the same principle: if no live reference leads from a reachable closure’s Context to the big object, the GC is free to collect it. Without at least one of these, even big = undefined elsewhere does nothing — the Context slot is the retaining reference.

function makeHandlers() {
  const small = () => 42;            // its own scope, no big in sight
  (function setup() {
    const big = new Array(5_000_000).fill(0);
    process(big);                    // used here, captured by nothing long-lived
  })();                              // 'big' dies when setup() returns
  return small;                      // retains only its (tiny) Context
}
Quiz

Two arrows are defined in one scope; one reads a 40 MB array, the other reads nothing big. You return only the small one and drop the other. What happens to the array?

Quiz

Which change reliably lets the GC reclaim the big array while keeping the small long-lived closure?

Order the steps

Order the reachability path the GC walks that keeps the big array alive through a returned small closure.

  1. 1 A GC root references the returned closure (e.g. a module-level variable)
  2. 2 The closure holds a pointer to its defining Context
  3. 3 That shared Context holds a slot pointing at the big array
  4. 4 GC marks the big array reachable and does not free it
Common mistake

A subtle real-world version: a DOM event handler closes over a scope that also contains a reference to a large detached subtree or a previous render’s data. The handler is tiny and “obviously fine”, but it is registered on a long-lived element, so its Context — and everything context-allocated in that scope — outlives every render. The detached-DOM leak class is very often a shared-Context leak wearing a DOM costume.

Recall before you leave
  1. 01
    Precisely, what does a closure retain, and why is 'only what it uses' wrong?
  2. 02
    Walk through the canonical shared-Context leak.
  3. 03
    What are the three reliable fixes, and what does each do mechanically?
Recap

A closure in V8 is nothing exotic: it is a JSFunction holding a pointer to the Context of the scope where it was created, alongside its shared SharedFunctionInfo. Because the garbage collector keeps reachable objects, the closure keeps its Context alive, and the Context keeps every context-allocated variable in that scope alive. The decisive and surprising fact is that the compiler allocates one Context per scope, shared by all functions defined in it — so two sibling closures share a single Context, and a tiny returned closure pins every variable any sibling forced into that Context, including a multi-megabyte buffer it never reads. This is the canonical closure leak, and a heap snapshot exposes it as an object retained through a system / Context that is retained by the closure. The fixes all cut the path closure → Context → big object: null the variable after use, move it into a scope no surviving closure captures, or build the long-lived closure in a separate scope. The same pattern underlies many detached-DOM and per-request handler leaks — which is why this connects directly to the memory-leaks lesson in the garbage-collection unit. Now when you see a heap snapshot where a large buffer is retained by an unexpected system / Context, look for the sibling closures sharing that scope — one of them is keeping the door open.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.