๐Ÿณ IntuitiveFE
Login
โ† All concepts

Closures & GC

โฑ๏ธ ~3-minute bite ยท solve the sandbox to master

0%lesson
๐Ÿง’

5-Year-Old Metaphor

โ€” The physical, real-world picture. No jargon.

๐ŸŽ’ Every function is born with a backpack โ€” a permanent reference to the variables it was created next to.

The backpack analogy

When a function is created, it immediately slings on a backpack. Inside the backpack are references (not copies!) to every variable in its surrounding scope that it might need. The function carries this backpack wherever it goes โ€” even if it's returned from the outer function, passed as a callback, or stored in an array. As long as the function exists, everything in its backpack stays in memory.

The garderobe (the outer scope / Environment Record) can't be cleaned out while any function still has its backpack hooked to it. That's why closures can cause memory leaks โ€” the garderobe never empties.

The spec definition โ€” what a closure actually is

Every function has a hidden [[Environment]] internal slot. When a function is created (not called), the engine snapshots a pointer to the current Lexical Environment and stores it in this slot. A closure is simply a function + its [[Environment]].

Crucially: all functions in JavaScript are closures. There's no special syntax to "make a closure." Every function definition automatically captures its surrounding environment.

js
1function outer() {
2 const x = 10; // lives in outer's Lexical Environment
3 return function inner() { // inner's [[Environment]] โ†’ outer's env
4 return x; // walks up scope chain to find x
5 };
6}
7const fn = outer(); // outer's activation record is gone
8fn(); // 10 โ€” x still lives in the captured environment

References, not copies โ€” the critical distinction

The backpack contains references to bindings, not value snapshots. This is why multiple closures can share a binding and see each other's mutations โ€” and why the loop-with-var bug exists.

Reference (actual behaviour)

js
1let x = 1;
2const fn = () => x; // captures reference to x
3x = 42;
4fn(); // 42 โ€” sees mutation!

Snapshot (NOT how it works)

js
1let x = 1;
2// imagine: fn captures value 1
3x = 42;
4fn(); // 1 โ† this is wrong

Three things to know going in

  • Every function is a closure โ€” not just nested ones
  • Captured at creation time, not call time
  • References, not values โ€” mutations are visible across closures sharing the same binding
๐ŸŽ›๏ธ

Interactive Sandbox

โ€” Move something, see it react instantly.

Pattern

Code

js
1function makeCounter(start = 0) {
2 let count = start; // โ† captured
3 return () => ++count;
4}
5const a = makeCounter(0);
6const b = makeCounter(100);

Environment Record โ€” captured bindings

count (a) โ†’ 0 โ†’ 1 โ†’ 2โ€ฆ

heap-allocated โ€” closure keeps it alive

count (b) โ†’ 100 โ†’ 101โ€ฆ

separate binding โ€” a and b don't share

Call trace

โ€บa()//1
Memory: Each makeCounter() call creates its own Environment Record. a and b each have their own count โ€” completely independent.
Key insight: Two closures from the same factory capture SEPARATE bindings because each call creates a new Environment Record.
Explored:๐Ÿ”ขโš ๏ธโœ…๐Ÿ“ฆ๐Ÿšฟ
๐ŸŽฏ

Challenge

Explore all 5 closure patterns. Pay special attention to loop-var vs loop-let โ€” why they differ is the most common interview question.

Try it
๐ŸŽฏ

Why Should I Care?

โ€” The exact interview question + the bug it kills.

Exact interview questions

Q1: "Implement a counter factory with private state"

js
1function makeCounter(init = 0) {
2 let count = init;
3 return {
4 inc: () => ++count,
5 dec: () => --count,
6 reset: () => { count = init; },
7 get: () => count,
8 };
9}
10const c = makeCounter(10);
11c.inc(); // 11
12c.inc(); // 12
13c.dec(); // 11
14c.count; // undefined โ€” private!

Follow-up: "What if I call makeCounter twice โ€” do the two counters share count?" Answer: No โ€” each call creates a new Environment Record with its own count binding.

Q2: "What does this log and how do you fix it?"

js
1const fns = [];
2for (var i = 0; i < 3; i++) {
3 fns.push(() => i);
4}
5console.log(fns.map(f => f())); // ???
6ย 
7// Fix 1: let
8for (let i = 0; i < 3; i++) { fns.push(() => i); }
9ย 
10// Fix 2: IIFE (pre-ES6)
11for (var i = 0; i < 3; i++) {
12 (function(j) { fns.push(() => j); })(i);
13}

Answer: [3, 3, 3]. All three closures reference the same var binding. After the loop, i=3. Fix: use let โ€” each iteration creates a new block-scoped binding.

Q3: "Implement memoize using closures"

js
1function memoize(fn) {
2 const cache = new Map(); // private via closure
3 return function(...args) {
4 const key = JSON.stringify(args);
5 if (cache.has(key)) return cache.get(key);
6 const result = fn(...args);
7 cache.set(key, result);
8 return result;
9 };
10}
11const fib = memoize((n) =>
12 n <= 1 ? n : fib(n - 1) + fib(n - 2)
13);

The cache Map persists between calls because the returned function closes over it. It's completely private โ€” no external code can access or corrupt it.

Production memory leaks closures cause

Leak 1: Unremoved event listeners

js
1// Every call to init() adds a new listener that
2// captures bigPayload โ€” and never gets removed
3function init() {
4 const bigPayload = fetch('/api/huge-data');
5 window.addEventListener("resize", () => {
6 // uses bigPayload...
7 });
8}
9// Fix: always store handler ref and removeEventListener on teardown

Leak 2: setInterval not cleared

js
1// In React or any component:
2function startPolling(data) {
3 setInterval(() => sendMetrics(data), 5000);
4 // If the component unmounts without clearInterval,
5 // data (and the timer closure) stays in memory forever.
6}
7// Fix:
8useEffect(() => {
9 const id = setInterval(() => sendMetrics(data), 5000);
10 return () => clearInterval(id); // cleanup on unmount
11}, [data]);

Leak 3: Detached DOM nodes

js
1let node = document.getElementById("modal");
2node.addEventListener("click", () => doWork(node));
3document.body.removeChild(node);
4// node removed from DOM โ€” but JS still holds a reference
5// to it via the closure. Browser can't GC it.
6// Appears as "Detached HTMLDivElement" in heap snapshots.
7node = null; // โ† must also clear your own reference

When closures DON'T prevent GC

If a closure captures a variable but the closure itself is no longer reachable (no live reference to the function), the GC can reclaim both the closure and everything it captured. The leak only persists while the function reference is alive:

js
1function makeHeavy() {
2 const huge = new Array(1_000_000);
3 return () => huge.length;
4}
5let fn = makeHeavy(); // huge stays alive (fn references the closure)
6fn = null; // fn dropped โ†’ closure unreachable โ†’ huge is GC'd
๐Ÿ”ฌ

The Deep Dive

โ€” Spec refs, engine internals, the minutiae.

V8 internals: Context objects & heap allocation

V8 does not blindly capture everything in scope. During compilation it performs a static analysis pass to determine which variables are actually referenced by inner functions. Only those variables are placed in a Context object on the heap. Everything else stays on the stack (much faster).

js
1// x stays on stack โ€” nothing captures it
2function outer(x) {
3 function inner() { console.log("hello"); }
4 return inner;
5}
6ย 
7// y goes to heap (Context object) โ€” inner captures it
8function outer(x, y) {
9 function inner() { console.log(y); }
10 return inner;
11}
12ย 
13// Floats get boxed as HeapNumber if captured
14function makeAdder(x) {
15 return (y) => x + y; // x=5.5 โ†’ HeapNumber on heap
16}

All closures created in the same scope that capture the same variable share one Context object โ€” they don't each get a copy. This is why the module pattern's inc, dec, and get all see the same count mutations.

WeakRef & FinalizationRegistry (ES2021)

WeakRef holds a weak reference to an object โ€” it doesn't prevent GC. If the GC decides to collect the object, deref() returns undefined.

js
1let obj = { data: "big" };
2const ref = new WeakRef(obj);
3ย 
4obj = null; // strong ref dropped
5// GC may now collect obj
6ย 
7const val = ref.deref(); // undefined if GC'd, obj if not
8ย 
9// FinalizationRegistry: callback when object is collected
10const registry = new FinalizationRegistry((key) => {
11 cache.delete(key); // opportunistic cache eviction
12});
13registry.register(obj, "cache-key-1");
Critical limitation: The V8 team says "if your application depends on GC cleaning up a WeakRef in a timely, predictable manner, it's likely to be disappointed." GC timing is non-deterministic. Use WeakRef only for opportunistic cleanup (e.g., cache invalidation), never for correctness-critical logic. Always provide explicit cleanup as the primary path.

Detecting closure leaks in Chrome DevTools

  1. Memory tab โ†’ Take heap snapshot (baseline)
  2. Perform the action you suspect leaks (open modal, click button, etc.)
  3. Take a second snapshot
  4. Select snapshot 2 โ†’ change dropdown to Comparison
  5. Sort by ฮ” Size (largest growth first)
  6. Look for Detached HTMLElement entries (removed DOM nodes kept alive) and (closure) entries
  7. Click a suspected object โ†’ check the Retainers pane โ€” it shows exactly which closure holds the reference

Pro tip: named functions appear as myHandler closure in snapshots. Anonymous arrow functions appear as (anonymous) โ€” impossible to tell apart. Always name your event handler functions.

Async functions and closure memory retention

The body of an async function is suspended at each await. Every variable in scope at the suspension point is captured in the continuation closure โ€” and held in memory until the promise settles.

js
1async function process() {
2 const bigBuffer = new ArrayBuffer(10_000_000); // 10 MB
3 const processed = transform(bigBuffer);
4ย 
5 // bigBuffer held in memory across this await โ†“
6 const result = await fetch("/api/save", { body: processed });
7ย 
8 // Fix: null out early
9 const processed2 = transform(bigBuffer);
10 bigBuffer = null; // release before await
11 const result2 = await fetch("/api/save", { body: processed2 });
12}

Closure edge cases worth knowing

  • Arrow functions as class fields create a new function per instance (vs prototype methods which are shared). Useful for this-safe callbacks but increases per-instance memory. Each instance gets its own closure over this.
  • eval() forces all locals to the heap โ€” because the engine can't know at compile time which variables eval will reference. Avoid eval in any closure-heavy code.
  • Destructuring doesn't limit captures โ€” const { a } = big still captures the whole big binding in the closure's environment. Extract primitives by assignment, not destructuring, to allow early GC of the parent object.
  • typeof on a closure's captured variable is always safe โ€” TDZ doesn't apply to variables that are already initialized in the outer scope by the time the closure runs.

Closure-based patterns to know cold

once(fn)

Executes fn exactly once; caches result. Uses called flag + result in closure.

debounce(fn, ms)

Stores timeoutId in closure; clears and resets on each call.

throttle(fn, ms)

Stores lastRun timestamp in closure; skips calls within the window.

memoize(fn)

Stores cache Map in closure; returns cached result on hit.

partial(fn, ...args)

Returns new fn that prepends stored args to any future call.

createStore(reducer)

Stores state + listeners in closure โ€” the Redux core pattern.

๐ŸŽค

Interview Questions

โ€” Real questions from real interviews โ€” with answers.

A closure is a function bundled with references to its surrounding lexical environment โ€” every function in JS is a closure.

Store a `called` flag and cached result in the closure; return cached result on subsequent calls.

Store a `timeoutId` in the closure; cancel and reset the timer on every call.

Closures capture a reference to the binding, so mutations to the variable are visible across all closures sharing it.

An active event listener closure keeps its entire captured scope alive โ€” remove listeners to let GC reclaim the memory.

๐ŸŽฎ

Memory Game

โ€” Quick quiz โ€” lock the concept in long-term memory.
1/4

Why does `eval()` inside a function prevent V8 from optimising variable allocation?