⚡ AMP
JavaScript

Understanding JavaScript closures with practical examples

A practical guide to understanding JavaScript closures with practical examples.

Nitheesh DR 4 min read

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

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.