Engineering Notes
Home/Languages/JavaScript/Closures
Languages · JavaScript

Closures

A closure is a function bundled together with references to its surrounding lexical scope. The function keeps access to variables from the scope it was created in, even after that outer scope has finished executing.

💻 Languages● Beginner⏱ 8 min read🛠️ 4 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Definition
  • How This Works Mechanically
  • Closures Capture Variables, Not Values
  • Practical Use: Data Privacy
  • What a Closure Actually Keeps Alive
Part 1The Core Concept

1Definition

A closure is a function bundled together with references to its surrounding lexical scope. The function keeps access to variables from the scope it was created in, even after that outer scope has finished executing.

js
function makeGreeter(greeting) {
  return function (name) {
    return `${greeting}, ${name}!`;
  };
}

const greetHello = makeGreeter("Hello");
console.log(greetHello("Ana")); // "Hello, Ana!"

makeGreeter("Hello") returns and its call stack frame is gone — yet the inner function still reaches greeting. That's the closure at work: it didn't copy the value, it kept a live reference to the variable.

outer()'s call frame is gone, but inner() keeps a pointer back to its Context, keeping count alive
outer()'s call frame is gone, but inner() keeps a pointer back to its Context, keeping count alive

2How This Works Mechanically

Every function created in JavaScript carries an internal reference to the lexical environment it was defined in. As long as any reference to the inner function exists, that environment (and everything it needs) is kept alive by the garbage collector — it is not destroyed just because the outer function returned.

js
function counter() {
  let count = 0;
  return {
    increment: () => ++count,
    decrement: () => --count,
    value: () => count,
  };
}

const c = counter();
c.increment();
c.increment();
console.log(c.value()); // 2

All three inner functions share the same closure over count — they read and write the same live variable, not separate copies.

3Closures Capture Variables, Not Values

This is the single most important — and most misunderstood — fact about closures.

js
function badLoop() {
  var fns = [];
  for (var i = 0; i < 3; i++) {
    fns.push(() => console.log(i));
  }
  fns.forEach(fn => fn()); // 3, 3, 3 — NOT 0, 1, 2
}
badLoop();

Because var i is function-scoped, all three closures capture the same i. By the time the callbacks run, the loop has finished and i is 3.

js
function goodLoop() {
  var fns = [];
  for (let i = 0; i < 3; i++) {
    fns.push(() => console.log(i));
  }
  fns.forEach(fn => fn()); // 0, 1, 2 — correct
}
goodLoop();

let creates a new binding of i for every iteration, so each closure captures its own independent variable.

4Practical Use: Data Privacy

Because a closure's captured variables aren't accessible from outside, closures are the standard way to create private state in JavaScript without classes:

js
function createBankAccount(balance) {
  return {
    deposit(amount) { balance += amount; return balance; },
    getBalance() { return balance; },
  };
}

balance cannot be read or modified except through the returned methods — there is no account.balance property at all.

⚠️ Common Pitfalls

  1. Loop variable capture with var — the #1 closure bug. Fix with let, or by wrapping in an IIFE that captures the value per iteration.
  2. Assuming a closure snapshots a value — it doesn't; it holds a live reference. If the outer variable changes later, every closure over it sees the new value.
  3. Memory leaks from long-lived closures — a closure keeps its entire lexical environment alive, not just the variables it uses. Holding a reference to a large object in an outer scope keeps it in memory as long as any inner closure exists.
Part 2Memory and Advanced Patterns

1What a Closure Actually Keeps Alive

A closure does not selectively keep only the variables it references — in most engines it keeps the entire lexical environment record of the enclosing scope reachable. Two consequences:

js
function outer() {
  const bigArray = new Array(1_000_000).fill("data"); // large
  const smallValue = 1;

  return function inner() {
    return smallValue; // only uses smallValue...
  };
}

const fn = outer(); // ...but in older/naive engines bigArray may be retained too,
                     // because inner and bigArray share the same environment record.

Modern V8 performs closure optimization and can discard genuinely unreferenced bindings, but you should not rely on this — don't hold large objects in a scope that a long-lived closure shares, if you can avoid it. Extract only what you need into a smaller scope first.

2Module Pattern (Pre-ES-Modules Encapsulation)

Before ES modules existed, closures were the primary way to build private state and a public API from an IIFE:

js
const Counter = (function () {
  let count = 0; // private, invisible outside this IIFE
  return {
    increment() { return ++count; },
    reset() { count = 0; },
  };
})();

Counter.increment(); // 1
Counter.increment(); // 2
Counter.reset();

3Memoization

Closures let a function remember results of previous calls in a private cache:

js
function memoize(fn) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

cache is private to each memoized function instance — inaccessible and untamperable from outside.

4Partial Application / Currying

js
function multiply(a) {
  return function (b) {
    return a * b;
  };
}

const double = multiply(2);
double(5); // 10

double is a closure that "remembers" a = 2 permanently.

5Once-Only Execution

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

called and result persist across calls to the returned function because of the closure, even though once() itself only runs a single time.

6Closures vs Classes for Private State

ClosuresClasses with #private
Privacy enforced✅ true (no access path exists)✅ true (since ES2022)
Shared state between instancesManual (module-level variable)Natural (per-instance fields)
Performance for many instancesEach closure duplicates its own functionsMethods shared on the prototype
Best forSmall, one-off stateful functionsMany instances of the same shape

For a handful of stateful objects, closures are fine. For thousands of instances, class-based private fields are more memory-efficient because methods live once on the prototype instead of being recreated per closure.

Under the HoodContext Objects, Precisely▾

A closure is not a copy of anything — mechanically, it is a function object holding a pointer to a Context object (see the Scope topic's "Under the Hood" section for the full Context-chain mechanics). Understanding this precisely resolves a lot of confusion about what closures "really" keep alive.

Only captured variables get promoted to the heap

js
function outer() {
  const captured = "kept alive";
  const notCaptured = "discarded when outer() returns";

  return function inner() {
    return captured; // only THIS variable forces a heap Context
  };
}

V8's compiler determines, per variable, whether any nested function references it. captured gets allocated into a heap Context object because inner reads it. notCaptured is never referenced by any inner function, so it stays a disposable stack slot and is gone the instant outer() returns — it costs nothing after that, regardless of how long inner (and its Context) survives.

Why "a closure keeps its whole scope alive" is a useful simplification, not the literal mechanism

js
function outer() {
  const bigArray = new Array(1_000_000).fill(0); // large
  const smallFlag = true;

  return function inner() { return smallFlag; }; // only touches smallFlag
}

If bigArray and smallFlag are declared in the same block and the compiler's analysis is conservative for that scope, both can end up in the same Context object (Contexts are typically allocated per-scope, not per-variable) — meaning bigArray rides along even though inner never touches it. This is the real, nuanced version of the "closures can leak memory" warning: the risk isn't per-variable, it's per-scope — anything sharing a block with a captured variable can be kept alive as a side effect of that Context's granularity. The practical fix from Part 1 (extract only what you need into a smaller, dedicated block before creating the closure) works specifically because it shrinks what that Context has to hold.

One Context per call, not per function

js
function counter() {
  let count = 0; // a NEW Context object is allocated on EVERY call to counter()
  return () => ++count;
}
const c1 = counter();
const c2 = counter();

c1 and c2 hold pointers to two entirely separate Context objects — this is why they track independent counts. Nothing is shared between them except the shape of the code; the actual storage is allocated fresh per invocation of counter().

⚠️ Common Pitfalls (continued)

  1. Creating closures inside hot loops — every call to an outer function creates new function objects for its closures; doing this in a tight loop (e.g. attaching a new closure per array element per frame) can hurt performance.
  2. Forgetting closures are per-call, not per-function — calling counter() twice produces two independent closures over two independent count variables.
  3. Debugging "stale" values — if a closure appears to read an old value, check whether you intended to capture a snapshot (you didn't — closures are always live) versus a fresh variable per iteration (need let, not var).

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is a closure?core▾

A function together with references to the variables of the scope in which it was created. The inner function can access those variables even after the outer function returned.

Follow-ups interviewers ask
  • Does it copy values? No, it keeps references to the variables, so it sees later changes.
Q2Give practical uses of closures.core▾

Data privacy/encapsulation (module pattern, private counters), function factories (makeMultiplier(2)), memoization caches, partial application/currying, event handlers capturing context, and debounce/throttle implementations.

Follow-ups interviewers ask
  • How does debounce use closures? The timer ID lives in the closure between calls.
Q3Can closures cause memory leaks?core▾

Yes, if long-lived closures (event listeners, timers, caches) capture large objects that are no longer needed, keeping them reachable. Remove listeners and clear timers.

Follow-ups interviewers ask
  • What do engines optimize? Only variables actually referenced are retained in many engines.
Q4Implement a function that can be called only once.core▾

function once(fn) { let done = false, result; return (...args) => { if (!done) { done = true; result = fn(...args); } return result; }; } The closure keeps done and result.

Follow-ups interviewers ask
  • Real use? Initialization logic.
Q5What are stale closures in React?senior▾

Callbacks capturing state values from an earlier render; when called later (intervals, async), they see old values. Fix with correct dependencies, functional updates or refs.

Follow-ups interviewers ask
  • Root cause? Each render creates new closures over that render's values.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.