Understanding JavaScript Closures with Practical Examples
You write a loop that creates three buttons, each supposed to log its own index when clicked. All three log 3. If you've hit this, you've already met closures — you just didn't know their name yet.
for (var i = 0; i < 3; i++) {
document.getElementById(`btn${i}`).onclick = function () {
console.log(i);
};
}
// clicking any button logs 3
A closure is a function bundled with references to the variables from the scope it was created in — not copies, references. Since var is function-scoped, all three callbacks share the same i, and by the time you click anything, the loop has finished and i is 3.
The fix, and why it works
for (let i = 0; i < 3; i++) {
document.getElementById(`btn${i}`).onclick = function () {
console.log(i);
};
}
// logs 0, 1, 2 correctly
let is block-scoped, so each loop iteration gets its own i. Each closure captures a different binding. This one-word fix trips up even experienced developers because the mental model of closures — "the function remembers its environment" — isn't obvious from the syntax.
Closures for private state
Before classes had private fields, closures were the standard way to hide state:
function createCounter() {
let count = 0;
return {
increment: () => ++count,
decrement: () => --count,
value: () => count,
};
}
const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.value()); // 2
console.log(counter.count); // undefined — not accessible from outside
count only exists inside the closure created by createCounter. Nothing outside can touch it directly — the returned object is the only interface. This is still a common pattern in module-style code and configuration factories, even with real private class fields (#count) now available.
A real bug: closures in async loops
async function fetchAll(urls) {
const results = [];
for (const url of urls) {
results.push(fetch(url).then(r => r.json()));
}
return Promise.all(results);
}
Each iteration's url is correctly captured because for...of with const creates a new binding per iteration — same reasoning as the let fix above. Swap it for var url in an old-style for loop and you're back to the same bug: every promise resolves using whatever url ended up being when the loop finished.
Memory implications
Closures keep their captured variables alive as long as the closure itself is reachable — this is exactly how they work, but it means a long-lived closure holding a reference to a large object (a big array, a DOM node) prevents garbage collection of that object until the closure itself is released. Event listeners are the classic leak source:
function attachHandler(largeData) {
document.getElementById("btn").addEventListener("click", () => {
console.log(largeData.length); // keeps largeData alive
});
}
If that button is never removed and the handler never detached, largeData stays in memory for the life of the page.
Common mistakes
- Assuming closures copy values — they capture references/bindings, not snapshots.
- Using
varinside loops that create callbacks (React effect cleanups, event handlers, setTimeout). - Creating closures inside hot loops when you don't need per-iteration state — this allocates a new function every iteration, which adds up in performance-sensitive code.
- Forgetting to remove event listeners that closures are attached to, causing memory leaks in long-running SPAs.
What I'd actually use
Default to const/let everywhere and this entire class of bug mostly disappears — the language got the ergonomics right in ES6, and there's little reason to reach for var in new code. When you deliberately want shared mutable closure state (like the counter example), that's a legitimate, common pattern — don't avoid closures out of fear, just be intentional about what they capture.
Next steps
Search your codebase for var inside any for loop that creates a callback — that's the highest-value place to check for this bug. Then try rewriting one stateful class as a closure-based factory function; it's a good way to internalize how the captured scope actually behaves.