Closures & GC
โฑ๏ธ ~3-minute bite ยท solve the sandbox to master
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.
| 1 | function 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 | } |
| 7 | const fn = outer(); // outer's activation record is gone |
| 8 | fn(); // 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)
| 1 | let x = 1; |
| 2 | const fn = () => x; // captures reference to x |
| 3 | x = 42; |
| 4 | fn(); // 42 โ sees mutation! |
Snapshot (NOT how it works)
| 1 | let x = 1; |
| 2 | // imagine: fn captures value 1 |
| 3 | x = 42; |
| 4 | fn(); // 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
| 1 | function makeCounter(start = 0) { |
| 2 | let count = start; // โ captured |
| 3 | return () => ++count; |
| 4 | } |
| 5 | const a = makeCounter(0); |
| 6 | const b = makeCounter(100); |
Environment Record โ captured bindings
heap-allocated โ closure keeps it alive
separate binding โ a and b don't share
Call trace
Challenge
Explore all 5 closure patterns. Pay special attention to loop-var vs loop-let โ why they differ is the most common interview question.
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview questions
Q1: "Implement a counter factory with private state"
| 1 | function makeCounter(init = 0) { |
| 2 | let count = init; |
| 3 | return { |
| 4 | inc: () => ++count, |
| 5 | dec: () => --count, |
| 6 | reset: () => { count = init; }, |
| 7 | get: () => count, |
| 8 | }; |
| 9 | } |
| 10 | const c = makeCounter(10); |
| 11 | c.inc(); // 11 |
| 12 | c.inc(); // 12 |
| 13 | c.dec(); // 11 |
| 14 | c.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?"
| 1 | const fns = []; |
| 2 | for (var i = 0; i < 3; i++) { |
| 3 | fns.push(() => i); |
| 4 | } |
| 5 | console.log(fns.map(f => f())); // ??? |
| 6 | ย |
| 7 | // Fix 1: let |
| 8 | for (let i = 0; i < 3; i++) { fns.push(() => i); } |
| 9 | ย |
| 10 | // Fix 2: IIFE (pre-ES6) |
| 11 | for (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"
| 1 | function 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 | } |
| 11 | const 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
| 1 | // Every call to init() adds a new listener that |
| 2 | // captures bigPayload โ and never gets removed |
| 3 | function 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
| 1 | // In React or any component: |
| 2 | function 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: |
| 8 | useEffect(() => { |
| 9 | const id = setInterval(() => sendMetrics(data), 5000); |
| 10 | return () => clearInterval(id); // cleanup on unmount |
| 11 | }, [data]); |
Leak 3: Detached DOM nodes
| 1 | let node = document.getElementById("modal"); |
| 2 | node.addEventListener("click", () => doWork(node)); |
| 3 | document.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. |
| 7 | node = 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:
| 1 | function makeHeavy() { |
| 2 | const huge = new Array(1_000_000); |
| 3 | return () => huge.length; |
| 4 | } |
| 5 | let fn = makeHeavy(); // huge stays alive (fn references the closure) |
| 6 | fn = 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).
| 1 | // x stays on stack โ nothing captures it |
| 2 | function outer(x) { |
| 3 | function inner() { console.log("hello"); } |
| 4 | return inner; |
| 5 | } |
| 6 | ย |
| 7 | // y goes to heap (Context object) โ inner captures it |
| 8 | function outer(x, y) { |
| 9 | function inner() { console.log(y); } |
| 10 | return inner; |
| 11 | } |
| 12 | ย |
| 13 | // Floats get boxed as HeapNumber if captured |
| 14 | function 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.
| 1 | let obj = { data: "big" }; |
| 2 | const ref = new WeakRef(obj); |
| 3 | ย |
| 4 | obj = null; // strong ref dropped |
| 5 | // GC may now collect obj |
| 6 | ย |
| 7 | const val = ref.deref(); // undefined if GC'd, obj if not |
| 8 | ย |
| 9 | // FinalizationRegistry: callback when object is collected |
| 10 | const registry = new FinalizationRegistry((key) => { |
| 11 | cache.delete(key); // opportunistic cache eviction |
| 12 | }); |
| 13 | registry.register(obj, "cache-key-1"); |
Detecting closure leaks in Chrome DevTools
- Memory tab โ Take heap snapshot (baseline)
- Perform the action you suspect leaks (open modal, click button, etc.)
- Take a second snapshot
- Select snapshot 2 โ change dropdown to Comparison
- Sort by ฮ Size (largest growth first)
- Look for Detached HTMLElement entries (removed DOM nodes kept alive) and (closure) entries
- 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.
| 1 | async 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 overthis. - eval() forces all locals to the heap โ because the engine can't know at compile time which variables
evalwill reference. Avoid eval in any closure-heavy code. - Destructuring doesn't limit captures โ
const { a } = bigstill captures the wholebigbinding 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.Why does `eval()` inside a function prevent V8 from optimising variable allocation?