Closures and Scope
The mechanism behind callbacks, module privacy, React hooks and most memory leaks. Worth understanding properly once.
A closure is a function that remembers the variables where it was defined, even after that outer function has returned.
function counter() {
let count = 0; // lives on, because the inner function uses it
return function () {
count += 1;
return count;
};
}
const next = counter();
next(); // 1
next(); // 2
const other = counter();
other(); // 1 — its own independent countcount is not garbage collected when counter() returns, because the returned function still references it. Each call to counter() creates a fresh binding.
Scope#
let outer = "visible everywhere below";
function example() {
let fnScoped = "visible in this function";
if (true) {
let blockScoped = "only in this block";
var functionScoped = "hoisted to the whole function";
}
console.log(blockScoped); // ReferenceError
console.log(functionScoped); // works — this is why let is a problem
}let and const are block-scoped; var is function-scoped and hoisted. That difference is the single best reason never to write var.
The classic loop bug#
The most cited closure gotcha, and a good demonstration of why:
const fns = [];
for (var i = 0; i < 3; i++) {
fns.push(() => console.log(i));
}
fns.forEach((f) => f()); // 3, 3, 3There is only one i — var gives the whole function one binding — and by the time the callbacks run, the loop has finished and i is 3.
for (let i = 0; i < 3; i++) {
fns.push(() => console.log(i));
}
fns.forEach((f) => f()); // 0, 1, 2let creates a new binding per iteration, so each closure captures its own. Using let in loops fixes this for free.
Private state#
Before #private class fields, closures were how JavaScript did encapsulation — and they are still a clean way to do it:
function createAccount(openingCents) {
let balanceCents = openingCents; // genuinely inaccessible outside
return {
deposit(cents) {
if (cents <= 0) throw new RangeError("must be positive");
balanceCents += cents;
},
get balance() { return balanceCents / 100; },
};
}
const acc = createAccount(1000);
acc.deposit(500);
acc.balance; // 15
acc.balanceCents; // undefined — there is no way to reach itNo this, no new, no binding problems. For many objects this is simpler than a class.
Partial application#
function withPrefix(prefix) {
return (message) => `[${prefix}] ${message}`;
}
const warn = withPrefix("WARN");
warn("disk almost full"); // "[WARN] disk almost full"The returned function has prefix baked in. This is the pattern behind most middleware and configuration helpers.
Closures and memory#
The part that causes real bugs. A closure keeps everything in its scope alive, not just the variables it uses:
function makeHandler(hugeBuffer) {
const size = hugeBuffer.length;
return () => console.log(size); // still retains hugeBuffer in many engines
}The safe pattern is to extract what you need and let the rest go:
function makeHandler(hugeBuffer) {
const size = hugeBuffer.length;
return () => console.log(size);
}
// call it as: makeHandler(buf); buf = null;The more common leak is a listener that is never removed:
function subscribe(bigObject) {
emitter.on("tick", () => use(bigObject)); // never removed
}Every call adds a listener, each holding its captured scope, and the emitter's array grows forever. Node's MaxListenersExceededWarning is the tell — treat it as a leak report, not noise.
function subscribe(bigObject) {
const handler = () => use(bigObject);
emitter.on("tick", handler);
return () => emitter.off("tick", handler); // give callers a way to unsubscribe
}Returning an unsubscribe function is the pattern worth adopting everywhere — it is what React's useEffect cleanup is.
Where you meet closures without noticing#
// every callback
items.map((item) => item.price * taxRate); // captures taxRate
// event handlers
button.addEventListener("click", () => save(form));
// React hooks — a stale closure is the most common hook bug
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // count is captured once and never updates — use the updater formThat last one is worth recognising: with an empty dependency array the effect captures count from the first render forever. setCount((c) => c + 1) avoids the capture entirely.
Exercise#
// 1. Write once(fn) returning a function that runs fn only the first time
// and returns the cached result after that.
// 2. Write createRateLimiter(maxCalls, windowMs) using closure state.
// 3. Explain in a comment why this logs 3,3,3 and fix it:
const fns = [];
for (var i = 0; i < 3; i++) fns.push(() => console.log(i));
fns.forEach((f) => f());Common questions#
Are closures slow?#
No — they are fundamental to how JavaScript works and engines optimise them heavily. The cost is memory, not speed: a closure keeps its scope alive, so a long-lived one holding a large object keeps that object alive too.
When should I use a closure instead of a class?#
When you want genuine privacy with no this, or when an object has a small, fixed set of behaviours. Classes suit types with many methods, inheritance, or where a framework expects them. For a counter, a rate limiter or a configured helper, a closure is usually simpler.
Why does my React state seem stale inside a callback?#
Because the callback captured the value from the render it was created in. Either add the value to the dependency array so a fresh closure is made, or use the functional updater form, which reads the current value rather than a captured one.
Get the JavaScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for JavaScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.