Closures & scope
Scope: what a function can see
JavaScript scope is lexical: a function can read the variables of the place where it is written, not where it is called. Inner functions see outer variables. Lookup goes inner → outer → global and stops at the first match, so an inner name shadows an outer one.
Three kinds of scope: global, function, and block. let and const respect {} blocks; var ignores them and belongs to the whole function.
A closure is remembering that survives return
A function created inside another function keeps access to the outer variables even after the outer function has returned:
function makeCounter() {
let count = 0;
return () => ++count;
}
const counter = makeCounter(); // makeCounter is DONE here
counter(); // 1
counter(); // 2 — count is still alive
count didn't die when makeCounter returned, because the returned arrow function still uses it. That pairing of a function with the variables around it is a closure.
Each call gets its own variables
Call the factory twice and you get two independent counters:
const a = makeCounter();
const b = makeCounter();
a(); a(); // 2
b(); // 1 — b has its OWN count
Every call to makeCounter creates a fresh set of its variables, so a and b closed over different counts. Interviewers test this fact more than anything else about closures. The flip side matters too: nothing outside can read or change count except through the returned function.
Where you already use this
Every callback that touches an outer variable is a closure: a setTimeout reading a variable from the surrounding function, an event handler reading state, an array map using a parameter. Everyday JavaScript runs on closures. Why the variable stays alive, and one famous trap with loops, is covered in the middle notes.
Closures capture variables, not values
A closure holds a reference to the variable itself: a live binding, not a copy of the value it saw at creation time:
let value = 1;
const read = () => value;
value = 2;
read(); // 2 — not 1
"The function saved the value it had when it was created" is the classic wrong answer. It reads the current value every time it runs. Most closure bugs, and most closure interview questions, reduce to this distinction.
The classic loop trap
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j)); // 0 1 2
With var there is one i for the whole loop. All three callbacks close over that same variable, the loop finishes first, and by the time they run i is 3. With let each iteration gets a fresh binding, so each callback sees its own value. Know the pre-let fixes too: an IIFE that takes i as a parameter, a factory function returning the callback, or forEach, which gives every call its own scope.
Private state and shared environments
Nothing outside can reach a closed-over variable, which makes this real encapsulation, enforced by the language rather than by a naming convention:
const wallet = (() => {
let balance = 0;
return { deposit: (n) => (balance += n), get: () => balance };
})();
wallet.deposit(50);
wallet.get(); // 50
wallet.balance; // undefined — no way in
deposit and get share one balance: functions created in the same call close over the same environment. Two functions from one factory call share state; functions from different calls don't. Interviewers probe exactly this boundary.
This is everywhere in your code
debounce, throttle, memoize, once: every classic utility is a closure over some hidden state (a timer id, a cache, a flag). React hooks lean on closures too, which is where "stale closure" bugs come from: an effect captured a variable from a render that is no longer current. What closures cost in memory, and how the engine implements them, is in the senior notes.
The spec model: environments
Every scope is a Lexical Environment: an environment record (the variable bindings) plus a reference to the outer environment. Every function carries a hidden [[Environment]] pointer to the environment where it was created. Calling the function creates a new environment whose outer reference is that pointer, and variable lookup just walks this chain. A closure is nothing beyond that pointer keeping a chain of records reachable.
This model derives the loop behavior instead of memorizing it. for (let i...) works because the spec creates a fresh environment record per iteration and copies the binding forward; var has a single function-level record, so every callback's chain leads to the same slot. Pre-ES6, teams simulated per-iteration records with IIFEs. Same mechanism, built by hand.
How closures leak memory
GC in JavaScript is reachability-based (mark-and-sweep from roots), not reference counting, so cycles are collected fine. What never gets collected: anything reachable through a live closure. A closure pins its environment chain for as long as the closure itself is alive:
function attach(el) {
const big = new Array(1e6).fill("*");
el.onclick = () => console.log(big.length); // pins `big` for el's lifetime
}
Real leaks are variations on this snippet: long-lived listeners and timers capturing large objects, caches in module scope, detached DOM nodes held by handlers. The fixes: unsubscribe or clear on teardown, narrow what you capture (const len = big.length and close over that), or key caches by object with WeakMap so entries die with their keys. In DevTools, a heap snapshot shows the retained object with its retainer path running through the closure; interviewers read fluency with that path as a senior signal.
Engine reality and design trade-offs
Two facts about real engines: V8 may drop captured variables the closure never mentions (which is why the debugger sometimes can't see an outer variable you expect), and sibling closures over one scope share one environment object, so one closure needing one variable can keep alive everything its siblings capture.
Then the design question. Closures are one of three tools for private state. Per-instance closures give true privacy but allocate a set of functions per instance. Prototype methods are shared but public. #private class fields give privacy with shared methods. Interviews at this level ask you to pick one for a concrete case and defend the pick.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.