By Gaurav · · 15 min read

  • javascript
  • closures
  • fundamentals
  • memory
Cover illustration for Closures Are About Lifetime, Not Nesting

Most explanations of closures say: "a closure is a function that remembers the variables from the scope where it was created." That sentence is true, but it hides the part that matters. A closure does not capture a value. It captures a binding — the variable itself — and keeps that binding alive for as long as the function can still be called.

Once you internalize that, a lot of JavaScript stops being surprising. The loop that logs the same number three times, the event handler that sees yesterday's state, the memoized function that slowly eats memory, and the request object that never gets collected are all the same mechanism viewed from different angles. The mechanism is lifetime.

This article builds the model from the bottom up: how scope and environment records work, what a closure actually holds, why the classic loop bug happens, how this differs from a captured variable, where closures give you privacy, how they leak memory, and how to reason about them in async code. Every JavaScript snippet is runnable, so you can test your predictions instead of taking mine on faith.

The one-sentence model

A function value carries a hidden reference to the lexical environment in which it was created. When the function is called, identifier lookup starts in that environment and walks outward. The environment stays alive as long as any function that references it is reachable.

That is the whole idea. The rest is consequences.

Scope is a compile-time idea; environment is a runtime object

Lexical scope is determined by where code is written. The parser can tell, without running anything, that the count inside increment refers to the count declared in createCounter. This is why JavaScript is lexically scoped: you can find the binding by reading the source.

At runtime, each scope becomes an environment record: a data structure that maps names to bindings. A function call creates a function environment whose outer reference points at the environment where the function was defined. Variable lookup walks that chain. The chain is fixed when the function is created, not when it is called.

That last point is the difference between lexical and dynamic scope. If JavaScript were dynamically scoped, increment would look up count in whoever called it. It does not. It looks in the environment where createCounter ran, no matter who calls increment later or from where.

The ECMAScript specification models this with the [[Environment]] internal slot on function objects. When a function is called, the runtime creates a new function environment whose outer is [[Environment]], and evaluation of the body resolves identifiers against that chain. You do not need the spec to use closures, but the spec confirms the mental model: the reference is to an environment, not to a copy of the values inside it.

The canonical counter, read correctly

Here is the example everyone writes:

function createCounter(start = 0) {
  let count = start;

  return {
    increment() {
      count += 1;
      return count;
    },
    current() {
      return count;
    },
  };
}

const counter = createCounter(10);

console.log(counter.increment());
console.log(counter.increment());
console.log(counter.current());

When createCounter returns, its local scope would normally become unreachable. But increment and current were created inside that scope, and both reference count. Their environment reference keeps the scope — and therefore count — alive. The scope lives exactly as long as at least one of those functions is reachable.

Notice what is not happening. There is no hidden copy of 10 stored on increment. When increment runs, it reads and writes the same binding that current reads. That is why the third call sees 12 rather than 11. Sharing is the default, not an optimization.

This also explains a common source of confusion:

function makePair() {
  let value = 0;

  return {
    set(next) {
      value = next;
    },
    get() {
      return value;
    },
  };
}

const pair = makePair();

pair.set(42);
console.log(pair.get());

Two different functions, one binding. If closures copied values at creation time, get would still return 0. It returns 42 because both methods reference the same variable.

What the engine actually keeps

It is tempting to imagine every closure dragging its entire surrounding scope into the heap. The reality is more nuanced and engine-dependent, but the useful approximation is: a variable that is referenced by an inner function is allocated somewhere that outlives the call — in V8, typically a Context object on the heap — while variables that never escape can stay on the stack or in registers.

That optimization is not something you can rely on at the language level. The language guarantees that reachable closures see a live binding. It does not guarantee which variables the engine retains or when memory is reclaimed. In practice, engines are quite good at allocating only what is captured, but "captured" can include more than the one value you think you need, especially in large functions with many let declarations and nested functions.

The practical rule is: assume that anything referenced by a surviving closure is also surviving. If that assumption is expensive, restructure the code so the closure references less.

This matters most in long-lived callbacks. A function passed to a global event emitter can live for the entire process. If it closes over a request context, a database connection, or a large buffer, that entire object graph stays reachable even after the request is finished. The closure is not leaking in the sense of a bug in the engine; it is doing exactly what closures do. The lifetime is simply longer than the programmer intended.

The loop bug is a binding problem

The classic loop bug is usually described as "closures capture the last value of i." That description is imprecise, and the imprecision makes the fix feel like a trick. The functions do not capture a value at all. They capture a binding, and all of them capture the same binding.

const fns = [];

for (var i = 0; i < 3; i++) {
  fns.push(() => i);
}

console.log(fns.map((fn) => fn()));

var is function-scoped. The loop does not create a new i per iteration; it reuses one binding in the surrounding function environment. Each arrow function closes over that single binding. By the time the functions are invoked, the loop has finished and the binding holds 3. Every function reads the same place in memory.

With let, the language creates a new binding per iteration:

const fns = [];

for (let i = 0; i < 3; i++) {
  fns.push(() => i);
}

console.log(fns.map((fn) => fn()));

Now the output is [0, 1, 2]. The arrow functions are identical. The loop is identical. The only change is the lifetime and identity of the binding. In specification terms, a let declaration in a for loop creates a per-iteration environment: after the body runs, the next iteration gets a fresh environment containing a copy of the loop variable. Functions created in one iteration keep that iteration's environment.

Before let existed, the workaround was an immediately invoked function expression:

const fns = [];

for (var i = 0; i < 3; i++) {
  fns.push(
    (function (captured) {
      return () => captured;
    })(i),
  );
}

console.log(fns.map((fn) => fn()));

The IIFE creates a new function scope on every iteration, so each arrow closes over a different binding. This is the same idea as let, expressed manually. Once you see it as "create a new binding per iteration," the IIFE stops looking like a weird ritual.

There is a related trap when the loop variable is mutated later:

let shared = 0;
const read = () => shared;

shared = 99;
console.log(read());

The closure sees 99, not 0, because the binding is live. If you need a snapshot, you have to create one explicitly:

let shared = 0;
const snapshot = shared;
const read = () => snapshot;

shared = 99;
console.log(read());

That is not a workaround for closures. It is a different program: one that chose to capture a new binding whose value never changes.

this is not a captured variable

The loop bug is about which binding is captured. this is a separate mechanism, and conflating the two is one of the most common sources of confusion.

A normal function does not close over this the way it closes over variables. Its this value is determined at call time, based on how the function is invoked: as a method, as a plain call, with call/apply/bind, or with new. Arrow functions are different: they do not have their own this binding at all. They resolve this lexically, from the enclosing environment.

const team = {
  name: "platform",

  regular() {
    return this.name;
  },

  makeReader() {
    // The arrow captures the `this` of this method call.
    return () => this.name;
  },
};

const reader = team.makeReader();

console.log(team.regular());
console.log(reader());

A regular method gets this from the call site. An arrow function defined inside that method captures the method's this, so reader() still sees team. The contrast is an arrow defined directly in an object literal: it does not get the object as this, because object literals do not create a this binding. That arrow captures the this of the surrounding scope instead, which is why "arrow as a method" does not behave like a method.

This distinction matters when you pass methods around:

const team = {
  name: "platform",
  describe() {
    return `team=${this.name}`;
  },
};

const detached = team.describe;

try {
  console.log(detached());
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}

detached() is a plain call, so this is undefined in strict mode (the worker runs snippets in strict mode), and accessing this.name throws. The method did not lose its closure; it never had one for this. Binding the method fixes it:

const team = {
  name: "platform",
  describe() {
    return `team=${this.name}`;
  },
};

const bound = team.describe.bind(team);
console.log(bound());

Now this is fixed at bind time. An arrow function defined inside describe would also capture the this of that call, which is why the "arrow inside a method" pattern works for callbacks:

const team = {
  name: "platform",
  members: ["ada", "grace"],

  list() {
    return this.members.map((member) => `${this.name}:${member}`);
  },
};

console.log(team.list());

The arrow passed to map uses the this of list. If it were a regular function, this would be determined by map, which does not set it, and the result would be wrong.

The takeaway: closures capture variables, and arrow functions additionally capture this. Regular functions receive this at call time. These are related but not the same rule.

Closures are the original privacy mechanism

Before class private fields, closures were the standard way to keep state inaccessible from the outside:

function createQueue() {
  const items = [];

  return {
    push(item) {
      items.push(item);
      return items.length;
    },
    take() {
      return items.shift();
    },
    get size() {
      return items.length;
    },
  };
}

const queue = createQueue();
queue.push("first");
queue.push("second");

console.log(queue.size);
console.log(queue.take());
console.log(queue.size);

There is no queue.items. The array is reachable only through the methods. A caller cannot reassign it, inspect it, or mutate it without going through the public API. This is encapsulation enforced by scope rather than by convention.

Modern JavaScript offers other options, and they are worth comparing:

Approach State location Enforced by Notes
Closure Lexical environment Scope Works everywhere, one object per instance, no prototype sharing
#private field Object slot Language brand check Real privacy, but tied to class syntax and instances
WeakMap External table keyed by object Module boundary Useful for adding private data to objects you do not control
Symbol key Object property Convention Not private; Object.getOwnPropertySymbols can find it

The class private field is not a closure. It is a branded internal slot: the runtime checks that the receiver actually has the private name before allowing access. That difference shows up when you try to borrow a method:

class Counter {
  #count = 0;

  increment() {
    this.#count += 1;
    return this.#count;
  }
}

const counter = new Counter();
const increment = counter.increment;

try {
  increment();
} catch (error) {
  console.log(error.name);
}

The method throws because this is undefined, and private field access requires a valid receiver. With a closure-based counter, detaching the method would still work because the state is in the environment, not on this. Neither behavior is universally better; they are different tradeoffs.

Closures also let you share private state across a set of functions without exposing it on an instance. That is the basis of the module pattern. A module is essentially a closure created once, with a curated export object.

Memoization is a closure with a cache

Memoization is the cleanest example of a closure doing useful work without any classes:

function memoize(fn) {
  const cache = new Map();

  return (input) => {
    if (cache.has(input)) {
      console.log(`cache hit: ${input}`);
      return cache.get(input);
    }

    const result = fn(input);
    cache.set(input, result);
    return result;
  };
}

let calls = 0;
const slowSquare = (n) => {
  calls += 1;
  return n * n;
};

const fastSquare = memoize(slowSquare);

console.log(fastSquare(9));
console.log(fastSquare(9));
console.log(`slowSquare calls: ${calls}`);

The cache lives in the closure. The second call never reaches slowSquare, so calls stays at 1. The memoized function is a new function value with its own private cache; two calls to memoize produce two independent caches.

This is also where the lifetime model bites. The cache lives as long as fastSquare is reachable. If the keys are unbounded — user IDs, URLs, request payloads — the cache grows without limit. A memoized function is a small, convenient memory leak waiting for traffic. Production memoization usually needs one of:

  • A bounded LRU cache with eviction
  • A WeakMap when keys are objects and should not be strongly retained
  • A TTL or explicit invalidation strategy
  • No memoization at all for cheap functions

Here is a tiny bounded cache, still using a closure:

function memoizeLast(fn, limit = 3) {
  const cache = new Map();

  return (input) => {
    if (cache.has(input)) {
      const value = cache.get(input);
      cache.delete(input);
      cache.set(input, value);
      return value;
    }

    const value = fn(input);
    cache.set(input, value);

    if (cache.size > limit) {
      const oldest = cache.keys().next().value;
      cache.delete(oldest);
    }

    return value;
  };
}

let calls = 0;
const square = (n) => {
  calls += 1;
  return n * n;
};

const cached = memoizeLast(square, 2);

cached(1);
cached(2);
cached(3);
cached(1);

console.log(`calls=${calls}`);

The cache is still a closure variable; the difference is that its lifetime now has a bound on size, not just on time.

Stale closures: the bug that keeps coming back

A stale closure is not a different mechanism. It is the normal mechanism with a lifetime mismatch: the function kept running, but the binding it captured is no longer the one the program considers current.

The clearest example is an event listener registered once but expected to read current state:

function createStore(initial) {
  let state = initial;
  const listeners = [];

  return {
    getState: () => state,
    setState(next) {
      state = next;
      listeners.forEach((listener) => listener());
    },
    subscribe(listener) {
      listeners.push(listener);
    },
  };
}

const store = createStore(0);

// A handler created once, closing over the state at subscription time.
let renderedState = store.getState();
store.subscribe(() => {
  console.log(`handler sees ${renderedState}`);
});

store.setState(1);
renderedState = store.getState();
store.setState(2);

The handler always logs the renderedState variable. Because it is a live binding, it logs the latest assignment — but only if the handler reads the variable, not a copy. If the handler had closed over a function parameter holding a snapshot, it would log the old value forever. The bug appears when the code assumes a new function is installed on every render, but the lifetime of the old function was never ended.

This is exactly the class of bug React developers fight. Each render creates new closures over that render's props and state. An event handler from an earlier render is still a valid function; it simply closes over the earlier bindings. If a subscription or timer keeps the old handler alive, the UI reads stale data. The fixes are about lifetime and identity:

  • Re-subscribe when the captured value changes (dependency arrays)
  • Read the latest value through a ref or a getter instead of capturing a snapshot
  • Use functional updates so the update reads the previous state from the runtime, not from the closure
  • Tear down the old subscription when the component unmounts or the dependency changes

None of these are "closure workarounds." They are ways of making the intended lifetime explicit.

Async functions keep their environment alive across await

An async function is not a special kind of closure, but it is a useful stress test for the lifetime model. When an async function suspends at await, its local environment has to survive until the promise settles, because the rest of the function will resume later and read the same bindings.

async function loadOrder(id) {
  const startedAt = Date.now();

  console.log(`start ${id}`);
  await new Promise((resolve) => setTimeout(resolve, 10));

  // This local still exists after the await.
  console.log(`finish ${id} after ${Date.now() - startedAt >= 10}`);
}

await loadOrder(7);

The function does not restart from the beginning when the timer fires. Its execution context is resumed, with id, startedAt, and any other locals intact. That is the same environment mechanism, extended across suspension points. It also means that a long-running async function can retain a surprising amount of state: every local that is live across an await is reachable until the function completes or is abandoned.

Generators have the same property. A suspended generator keeps its environment so that the next next() call can continue. If a generator is never resumed and never released, its scope stays alive. Long-lived iterators that capture large datasets are a common source of retained memory.

The leak is the feature

Because a closure keeps its captured environment alive, it can keep much more than the programmer intended. The shape of the leak is almost always the same:

  1. A long-lived object holds a callback. That object could be a global event emitter, a singleton cache, a timer, a subscription, or a DOM listener.
  2. The callback closes over a short-lived object. That object could be a request context, a large buffer, a database connection, or a component instance.
  3. Nobody removes the callback.
  4. The short-lived object can never be collected.

The fix is not to avoid closures. It is to end the lifetime explicitly. In practice that means:

  • Remove event listeners when the component or operation that owns them is done.
  • Clear timers and intervals in cleanup paths.
  • Abort fetches and cancel subscriptions when a request is superseded.
  • Avoid capturing an entire object when the callback only needs one field.
  • Prefer WeakMap/WeakRef only when the semantics genuinely call for weak retention, not as a general leak cure.

The last point deserves emphasis. WeakMap is not a magic eraser for closures. It lets you associate data with an object without preventing that object from being collected. If the closure itself is reachable from a global, and the closure references the key object directly, the key is still strongly reachable through the closure. The data structure does not fix a lifetime bug; it only changes one edge of the graph.

Debugging these issues is usually a matter of finding the retaining path. Heap snapshots in browser DevTools and Node.js can show why an object is still reachable: which global, which listener list, which closure context points at it. The closure is rarely the root cause. The root cause is the long-lived thing that holds the closure.

Closures and performance, without folklore

There is a lot of folklore about closures being slow. The accurate version is narrower:

  • Creating a function value allocates. If you create a function inside a hot loop, you are allocating per iteration.
  • Captured variables may need heap allocation because they outlive the call. Engines optimize aggressively, but the language does not promise stack allocation.
  • Engines can inline and optimize closures very well, especially monomorphic ones. Microbenchmarks that claim "closures are slow" often measure deoptimization or allocation pressure, not the closure concept itself.
  • Deeply nested scopes with many captured variables can increase the size of retained environments. This is a memory concern more than a CPU concern.

The practical guidance is boring: do not allocate functions in tight loops if the function does not need per-iteration state, and do not build enormous nested closures around a hot path. Most application code does neither. Readability and correct lifetimes matter far more than shaving a function allocation.

A more interesting performance question is where the closure is created. A function created once at module load has a stable identity and a single environment. A function created per render or per request has a new identity and a new environment. Libraries that compare callbacks by identity — useEffect dependencies, event emitter removal, memoization keys — care about that difference. Creating a new closure on every render is normal and usually correct; failing to account for the new identity in a dependency array is the bug.

A short quiz you can run

Predict the output of each block before running it. The point is not the syntax; it is the binding lifetime.

One: which i?

const fns = [];

for (var i = 0; i < 2; i++) {
  fns.push(() => i);
}

i = 100;
console.log(fns.map((fn) => fn()));

All three functions read the same i, which is now 100.

Two: snapshot or live binding?

let value = 1;
const read = () => value;

value = 2;
console.log(read());

The closure sees the live binding, so the answer is 2.

Three: which environment does the inner function use?

function outer() {
  let label = "outer";

  function middle() {
    let label = "middle";

    return () => label;
  }

  return middle();
}

console.log(outer()());

The arrow is created inside middle, so it resolves label in middle's environment. The answer is "middle". The inner label shadows the outer one; lexical scope is determined by where the function is written, not where it is called.

Four: does this behave like a captured variable?

function makeReader() {
  return {
    value: 7,
    read: () => this?.value,
  };
}

const obj = makeReader();

console.log(obj.read());

No. The arrow captures the enclosing this, not obj. In the runner's strict-mode wrapper the enclosing this is undefined, so the result is undefined. Object literals do not create a this binding for their arrow properties.

The mental model, restated

When you see a function that references an outer variable, ask two questions:

  1. Which binding is this? Is there one shared variable, one per iteration, or one per call?
  2. How long does this binding need to live? A closure keeps the environment reachable, so the answer determines both correctness and memory.

The loop bug is question one. The stale handler is question one plus question two. The memory leak is question two. Private state and memoization are deliberate uses of question two. this is a separate binding mechanism that arrow functions happen to capture lexically.

Closures are not a syntax trick and they are not primarily about nesting. They are a statement about lifetime: this function may run later, so the things it references have to stay valid until then. Once you read them that way, the surprising behavior becomes a set of design decisions you can inspect, test, and change.