skip to content

In JavaScript, a function loads a large object and returns a small callback that will be stored for the lifetime of the process. How do you stop that callback from retaining the large object?

level: middleimportance: should knowfreq 42%

answer

  1. no free(): only stop pointing
  2. derive the small value first
  3. keep the big binding out of the callback's chain
  4. closures capture bindings, so null works
  5. delete removes properties, not bindings

basics

~20 s

Capture only the small value the callback needs, computing it before the callback is created, so the large object is never part of the callback's reachable graph. If it must be referenced, release the binding by assigning null once you are done with it.

solid answer

~50 s

Narrow the capture rather than trying to free memory afterwards, because the language gives you no `free()`. Compute the small thing you actually need — an id, a count, a formatted string — into its own `const` before you create the callback, and have the callback close over that instead of the big object. If the big object genuinely has to be touched inside the enclosing function, keep it in a scope the surviving callback does not sit inside: do the heavy work in a separate helper function so the big binding never lands in the callback's scope chain. As a last resort, when the same scope must both use and outlive it, set the variable to `null` after the last use; closures capture the binding, not the value, so the surviving callback then sees `null` and the object becomes collectable.

code

javascript · 14 lines
javascript
const registry = [];

function registerBad(rows) {
  registry.push(() => rows.length); // whole array retained for process lifetime
}

function registerGood(rows) {
  const count = rows.length;
  registry.push(() => count);       // one number retained
}

registerBad(new Array(1e6).fill(0));
registerGood(new Array(1e6).fill(0));
console.log(registry.map((f) => f()));

go deeper

for a junior

Know the practical habit: pass or compute the small value the callback needs before you create the callback, instead of letting it reference the whole object it was derived from.

for a middle

Explain why each fix works in terms of the reference chain from the stored callback to the object, and why closures capturing bindings rather than values makes assigning null an effective release.

for a senior

Demonstrate judgment about which cut point to choose in real code, and why nulling a binding is the fragile option that a later refactor silently removes, compared with never capturing the object at all.

for a principal

Frame it as an API-design rule: long-lived callback registries should accept plain data rather than closures over rich context, so retention is bounded by contract instead of by the care of whoever wrote the call site.

## Why "free it later" is not an option JavaScript has no destructor and no deallocation call. The only lever is reachability: an object is reclaimable when nothing reachable points at it. So the question "how do I release this?" always reduces to "what still points at it, and can I stop pointing?". For a long-lived callback, the pointer chain is: whatever holds the callback → the callback's captured environment → the binding → the object. That gives three places to cut, in decreasing order of preference. ## Technique 1: capture the value, not its owner The cheapest and most robust fix is to never put the big thing in the surviving closure's scope chain at all. Derive the small value first. ```js // retains the whole dataset function register(rows) { onTick(() => console.log(rows.length)); } // retains one number function register(rows) { const count = rows.length; onTick(() => console.log(count)); } ``` This also survives refactoring better than the alternatives, because the retained set is obvious from reading the callback: whatever identifiers it mentions. ## Technique 2: shrink the scope the callback is created in If the enclosing function must load the big object for its own work, move that work into its own function so the big binding lives in a scope the surviving callback is not nested inside. ```js function summarize(rows) { // rows is local to summarize return { count: rows.length, first: rows[0]?.id ?? null }; } function register(load) { const summary = summarize(load()); // big array is scoped to summarize's call onTick(() => console.log(summary.count)); } ``` A bare block does *not* achieve this on its own if the callback is created inside the block — the callback's chain then includes the block's record. Scope narrowing only helps when the surviving function sits outside the scope holding the big binding. ## Technique 3: release the binding explicitly When the same scope really must both use the object and produce a surviving callback, assign `null` over it after the last use. ```js function register() { let rows = loadHugeDataset(); const count = rows.length; process(rows); rows = null; // binding still exists, object no longer referenced return () => count; } ``` This works precisely because closures capture *bindings*, not copies: mutating the binding is visible to every closure over that scope, so nulling it genuinely removes the last reference. It needs `let` or `var` rather than `const`, and it is the technique to reach for last, because it is easy to delete by accident in a later refactor and nothing fails loudly when you do. ## Techniques that do not work - `delete rows` — `delete` removes object properties, not variable bindings; in strict mode (and therefore in every module) it is a syntax error on an identifier. - Reassigning a *different* variable that pointed at the same object — you must clear the reference the surviving closure can reach. - Wrapping the object in another object "so it can be dropped" — that adds a hop, not a release, unless you also clear the inner reference. - Calling any "gc" function — there is none in the language, and engine flags that expose one are debugging aids, not an API. ## Argument-level narrowing Destructuring in the parameter list is a real, tidy form of technique 1, because the parameter binding for the whole object never exists: ```js function register({ id, label }) { // the caller's big record is not captured onTick(() => `${id}:${label}`); } ``` Beware, though, that destructuring a property whose *value* is a big structure retains that structure just as firmly — narrowing is about what the extracted values point at, not about the syntax used to extract them. ## How you talk about this in an interview The strong answer names the retained graph explicitly — "the callback keeps its environment, the environment keeps `rows`, `rows` keeps the array" — then picks the cut point closest to the source. Reaching straight for `= null` without first asking whether the callback needed the object at all is the answer that reads as folklore rather than understanding.

  • Why does assigning null to the variable work at all, if the closure already captured it?
    Because a closure captures the binding, not a snapshot of its value. Both the enclosing code and the closure read the same slot in the same environment record, so writing `null` into it is immediately visible to the closure and removes the last reference to the object. That is also why the technique needs `let` or `var` — a `const` binding cannot be reassigned.
  • Does wrapping the heavy work in a plain block make the big object collectable?
    Only if the surviving closure is created outside that block. A block gets its own environment record for `let` and `const`, but a function created inside the block links to it, so the block's bindings stay reachable through that function. Moving the work into a separate function call is the reliable version of the same idea.
  • Is there any way to hold a value from inside a closure without preventing its collection?
    Yes, with `WeakRef` (ES2021): the closure holds a wrapper whose `deref()` returns the object or `undefined` once it has been collected. It is deliberately non-deterministic, so it suits caches and diagnostics where losing the value is acceptable, and never suits state your logic depends on.

saying these in an interview costs you the question

  • Reaches for a manual gc() call that the language does not have
  • Uses delete on a variable name to release it
  • Assumes a plain block scope drops the binding for closures inside it
  • Thinks reassigning any variable that pointed at the object is enough
  • Believes destructuring is free even when the extracted value is huge

context