Foundations Updated 2026-09 View as Markdown

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.

javascript
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 count

count is not garbage collected when counter() returns, because the returned function still references it. Each call to counter() creates a fresh binding.

Scope#

javascript
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:

javascript
const fns = [];
for (var i = 0; i < 3; i++) {
  fns.push(() => console.log(i));
}
fns.forEach((f) => f());     // 3, 3, 3

There 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.

javascript
for (let i = 0; i < 3; i++) {
  fns.push(() => console.log(i));
}
fns.forEach((f) => f());     // 0, 1, 2

let 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:

javascript
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 it

No this, no new, no binding problems. For many objects this is simpler than a class.

Partial application#

javascript
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:

javascript
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:

javascript
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:

javascript
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.

javascript
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#

javascript
// 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 form

That 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#

javascript
// 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.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.