Skip to content
JavaScript js advanced 5 min read

Closures in Depth

A closure is a function bundled together with references to the variables from the scope in which it was created. Every time you return a function from another function, pass a callback, or register an event handler, you are leaning on closures — usually without thinking about them. Understanding precisely what a closure captures (and when) is the difference between confidently reasoning about your code and being blindsided by the interview classics below.

What a closure actually captures

The single most important fact about closures: they capture variables, not values. A closure holds a live reference to the variable binding, so when the inner function finally runs, it reads whatever value that variable holds at call time — not the value it had when the closure was created.

function makeCounter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}

const next = makeCounter();
console.log(next());
console.log(next());
console.log(next());

Output:

1
2
3

The returned function keeps count alive even though makeCounter has already returned. Each call mutates the same binding, which is why the value accumulates rather than resetting.

The loop + var classic

This is the closure question that has tripped up a generation of developers. Because var is function-scoped, every iteration of the loop shares one single i binding. By the time the deferred callbacks run, the loop has finished and i holds its final value.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

Output:

3
3
3

All three callbacks close over the same i, and that one variable is 3 when they finally fire.

Fixing it

The cleanest fix is simply to use let, which is block-scoped: the loop creates a fresh binding per iteration, so each closure captures its own copy.

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

Output:

0
1
2

Before let existed (pre-ES2015), the idiom was to create a new scope manually with an IIFE:

for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 0);
  })(i);
}
ApproachMechanismOutput
var (broken)one shared binding3 3 3
letone binding per iteration0 1 2
IIFE + varnew function scope per iteration0 1 2

Reach for let/const by default. The only reason to know the IIFE workaround is to read legacy code and to answer it in interviews.

Practical patterns

Closures are the engine behind several everyday patterns. Here are the three you will use most.

The module pattern (private state)

Variables in the enclosing scope are invisible from the outside, so closures give you genuine encapsulation — only the methods you return can touch them.

const bankAccount = (function () {
  let balance = 0;
  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error("Insufficient funds");
      balance -= amount;
      return balance;
    },
    get balance() {
      return balance;
    },
  };
})();

bankAccount.deposit(100);
bankAccount.withdraw(30);
console.log(bankAccount.balance);

Output:

70

There is no way to set balance directly — bankAccount.balance = 999 would only shadow the getter, not the private variable.

The factory pattern (parameterised closures)

Capturing a configuration argument lets one factory stamp out specialised functions.

function multiplier(factor) {
  return (n) => n * factor;
}

const double = multiplier(2);
const triple = multiplier(3);

console.log(double(5), triple(5));

Output:

10 15

The once pattern

Closures can hold a flag that guarantees a function runs at most one time — handy for one-shot initialisation.

function once(fn) {
  let called = false;
  let result;
  return function (...args) {
    if (!called) {
      called = true;
      result = fn.apply(this, args);
    }
    return result;
  };
}

const init = once(() => {
  console.log("initialising...");
  return 42;
});

console.log(init());
console.log(init());

Output:

initialising...
42
42

Memory implications

Because a closure keeps its captured variables alive, those variables cannot be garbage-collected while the closure is reachable. This is normally exactly what you want — but it becomes a leak when a long-lived closure unintentionally pins a large object.

function attach() {
  const hugeBuffer = new Array(1_000_000).fill("x");
  const id = hugeBuffer.length;
  // Returning a closure that only needs `id` still risks
  // keeping `hugeBuffer` alive if the engine captures the whole scope.
  return () => id;
}

Modern engines (V8) are smart enough to drop variables a closure never references, but you should not rely on it for huge structures. If a closure only needs one small value extracted from a large object, copy that value into a local variable and let the big object fall out of scope.

Detached DOM nodes captured in event-handler closures are the most common real-world closure leak. Always remove listeners (or use AbortController / { once: true }) when a component unmounts.

Common interview questions

  • What is a closure? A function plus a persistent reference to its lexical environment.
  • Do closures capture by value or reference? By reference to the variable binding — the value is read when the inner function executes.
  • Why does the var loop log the final value three times? All callbacks share one function-scoped binding; let creates one binding per iteration.
  • How do you make private variables in JS? A closure (module pattern) or, in modern classes, #private fields.
  • Can closures cause memory leaks? Yes — by keeping captured variables (and anything they reference) reachable longer than intended.

Best Practices

  • Default to let/const; their block scoping prevents the entire class of loop-capture bugs.
  • Use closures for encapsulation, but prefer class #private fields when you are already writing a class.
  • Extract only the small values a closure needs instead of capturing whole large objects.
  • Clean up event listeners and timers that hold closures, especially around DOM nodes.
  • Keep closures small and named — a returned anonymous function with hidden state is hard to debug.
  • Remember that each call to a factory creates a brand-new set of captured variables; state is not shared between instances.
Last updated June 1, 2026
Was this helpful?