Closures are the topic interviewers reach for when they want to know whether you understand how JavaScript actually resolves names. The question usually arrives disguised as a five-line loop with a timer inside, and the candidate who can explain why it prints three threes, not just how to patch it, stands out immediately. This note builds up from lexical scope to that loop, then covers where closures show up in production code and the memory they can quietly hold on to.
Lexical scope in one minute
Scope is the set of variables a piece of code can see. JavaScript uses lexical (static) scope: the nesting of your source code decides what is visible. Every function call creates a new scope, and blocks create one for let, const and class. To resolve a name, the engine checks the current scope, then the enclosing one, and so on out to the global scope. That path is the scope chain.
const greeting = 'Hello';
function outer() {
const name = 'Ada';
function inner() {
console.log(`${greeting}, ${name}`); // looks up: inner → outer → global
}
inner();
}
outer(); // → Hello, AdaThe important word is written. Where a function is called has no effect on which variables it sees:
const x = 'global';
function logX() {
console.log(x);
}
function run() {
const x = 'local';
logX();
}
run(); // → globallogX was written at the top level, so its outer scope is the global one. Calling it from inside run does not give it access to run’s x. (The one part of JavaScript that is decided at call time is this, covered in the this keyword.)
What a closure actually is
A closure is a function bundled with a reference to the scope it was created in. Technically every JavaScript function is a closure, but the idea matters when a function outlives that scope: it is returned, stored, or passed along as a callback.
function makeCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // → 1
console.log(counter()); // → 2
const other = makeCounter();
console.log(other()); // → 1 (a fresh `count`)When makeCounter returns, its local count would normally be discarded. Because increment still references it, the engine keeps that scope alive. Each call to makeCounter creates a brand-new scope, which is why other starts again at 1.
The detail that sets up the loop question: a closure holds a live reference to the variable, not a copy of its value at creation time.
function makeGetter() {
let value = 'first';
const get = () => value;
value = 'second';
return get;
}
console.log(makeGetter()()); // → secondInterview tip
Define it precisely: “A closure is a function plus the lexical environment it was created in. It keeps access to those variables, live, even after the outer function has returned.” The word live is what explains the loop.
The classic setTimeout loop
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// → 3 3 3Two facts combine to produce this:
varis function-scoped (or global at the top level). There is exactly oneifor the whole loop, not one per iteration.- Timer callbacks run later.
setTimeoutschedules a task that can only run once the current script has finished and the call stack is empty (see the event loop). By then the loop has completed andiis 3, the value that failed thei < 3check.
All three arrow functions close over the same binding and read it when they finally run, so each sees 3. The delay does not change anything: with setTimeout(..., i * 1000) you still get three 3s, just a second apart. The delay is computed during the loop, while i is read afterwards.
Three ways to fix it
1. Use let
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// → 0 1 2let in a for header gets special treatment: the engine creates a fresh i for every iteration and copies the previous value into it before the increment runs. Each callback closes over its own binding.
2. Create a new scope per iteration
Before ES2015 the only block-free way to get a new scope was a function call. An IIFE (immediately invoked function expression) passes the current value in as a parameter, and each call gets its own parameter binding:
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 0);
})(i);
}
// → 0 1 2A named factory does the same thing and reads better:
function logLater(n) {
return () => console.log(n);
}
for (var i = 0; i < 3; i++) {
setTimeout(logLater(i), 0);
}
// → 0 1 23. Pass the value as an argument
setTimeout forwards any arguments after the delay to the callback, and they are evaluated at scheduling time. bind achieves the same by pre-filling arguments:
for (var i = 0; i < 3; i++) {
setTimeout((n) => console.log(n), 0, i);
}
// → 0 1 2
for (var k = 0; k < 3; k++) {
setTimeout(console.log.bind(console, k), 0);
}
// → 0 1 2Gotcha
Swapping
varforconstinfor (const i = 0; i < 3; i++)does not work: the firsti++throwsTypeError: Assignment to constant variable.Aconstper iteration is fine infor...of, where nothing is reassigned.
Closures in real code
Private state
Variables in a factory’s scope are unreachable from outside; only the functions created alongside them can read or change them.
function createAccount(initial) {
let balance = initial; // not reachable from outside
return {
deposit(amount) {
if (amount <= 0) throw new RangeError('Deposit must be positive');
balance += amount;
return balance;
},
getBalance() {
return balance;
},
};
}
const account = createAccount(100);
console.log(account.deposit(50)); // → 150
console.log(account.balance); // → undefined
console.log(account.getBalance()); // → 150Classes can now do this with #private fields, but closures remain the idiom for factory functions, modules and React hooks.
Memoization
The cache lives in the closure, so each memoized function gets its own:
function memoize(fn) {
const cache = new Map(); // lives as long as the returned function does
return function (arg) {
if (cache.has(arg)) return cache.get(arg);
const result = fn.call(this, arg);
cache.set(arg, result);
return result;
};
}
let calls = 0;
const slowSquare = (n) => {
calls += 1;
return n * n;
};
const fastSquare = memoize(slowSquare);
console.log(fastSquare(9), fastSquare(9), calls); // → 81 81 1This version keys on a single argument. Supporting several arguments means building a cache key, which is a good follow-up discussion.
once()
function once(fn) {
let done = false;
let result;
return function (...args) {
if (!done) {
done = true;
result = fn.apply(this, args);
fn = null; // drop the reference so `fn` can be garbage collected
}
return result;
};
}
const init = once(() => {
console.log('initializing…');
return 42;
});
console.log(init()); // → initializing… then 42
console.log(init()); // → 42 (no second log)The same pattern (state in the closure, a wrapper function returned) drives debounce and throttle.
Memory considerations
A closure keeps its outer variables alive for as long as the closure itself is reachable. That is the point, but it turns into a leak when a closure lives longer than you intended. The usual suspects are event listeners that are never removed, setInterval timers that are never cleared, and caches like the one in memoize that grow forever.
// Node 22 ships EventTarget and AbortController, same API as the browser
const button = new EventTarget();
function attach(target, signal) {
const bigData = new Array(1_000_000).fill('*');
target.addEventListener(
'click',
() => console.log(bigData.length), // closure keeps bigData alive
{ signal },
);
}
const controller = new AbortController();
attach(button, controller.signal);
button.dispatchEvent(new Event('click')); // → 1000000
controller.abort(); // listener removed; bigData can now be collected
button.dispatchEvent(new Event('click')); // → (nothing)The fixes are unglamorous: remove listeners (an AbortController signal or { once: true } makes this easy), clear timers, cap caches (an LRU, or a WeakMap when keys are objects), and set references to null once they are no longer needed, as once() does.
Note
In V8 (Chrome, Node), all closures created in the same scope share one context object. If any inner function references a large variable, that variable stays alive as long as any of those closures survives, even one that never mentions it. Variables no closure references are collected normally.
The interview answer
“JavaScript is lexically scoped: a function can see the variables of the scopes it was written inside. A closure is a function together with that surrounding environment, and it keeps a live reference to those variables even after the outer function has returned.
In the loop, var creates a single function-scoped i. The callbacks run after the loop has finished, so all three read the same binding, which is now 3. I’d fix it with let, which creates a fresh binding per iteration, or by capturing the current value in a new scope: an IIFE, a factory function, or setTimeout’s extra arguments. In real code I use closures for private state, memoization and helpers like once and debounce, and I watch for closures that outlive their purpose, such as listeners that are never removed.”