Closures

Closures let functions keep access to their lexical environment; understand shared bindings, loop variables, and callback lifetimes to use them reliably.

level intermediate time 14 min at Standard depth
version Node 24
what

A closure links a function to the lexical environment where it was defined. The function can still read or change the outer bindings it needs when it runs elsewhere.

trap

A closure retains bindings, not snapshots of their values at creation time. Several callbacks may share one variable that later changes.

fix

Decide which state each closure should share or own. Prefer let or a parameterized factory for loop callbacks, and give long-lived callbacks a cleanup path.

What it is and why it exists

A closure links a function to the lexical environment at its definition site. You can return the function, store it in a collection, or pass it to another API, and it can still resolve names visible where it was defined. The lexical lookup rule does not change when the outer function returns.

A name used in a function but not declared there is a free variable . The outer function scope that provides its binding is an enclosing scope . A closure depends on those bindings, not on same-named variables that happen to exist where someone calls it.

JavaScript uses lexical scope. A function’s position in the source determines where its names can resolve, not who later calls it. This design keeps a function meaningful when it travels as a value; otherwise, a returned function would lose the context needed to interpret its free variables.

Closures turn up throughout application code. Function factories retain configuration, counters and state machines retain private state, event handlers remember their component, and asynchronous callbacks reach data visible when work was scheduled. Top-level functions also have a creation environment, but nested functions that capture bindings from an outer call are usually the cases worth analyzing.

A closure is not a special function syntax. Function declarations, function expressions, and arrow functions can all form closures; what matters is whether the body refers to an outer binding. A nested function that uses only parameters, locals, and globals does not capture state from an enclosing function.

Closures suit small contexts with clear ownership. If state has many operations, needs public inspection, participates in inheritance, or has a lifecycle that is itself a business concept, a class or plain object is often easier to maintain. Choose by interface and ownership, not by whichever form uses fewer lines.

How it works

When JavaScript creates a function object, it associates the object with the lexical environment at that location. The environment maps names to bindings and links to an outer environment. When the function evaluates a free variable, lookup follows that lexical chain rather than inspecting the caller’s local scope.

Think of a binding as the storage location associated with a name. A closure later reads the value currently stored at that location, not a copy made when the function was created. If outer and inner code refer to the same binding, a reassignment by one is visible to the other.

A typical function-factory call follows this lifecycle:

  1. Calling the outer function creates parameters and local bindings for that call.
  2. Reaching the inner function definition creates a function object associated with the current lexical environment.
  3. The outer function returns the inner function; the bindings it needs remain available while that function is reachable.
  4. A later inner call resolves free variables from the environment associated at creation time.

Every outer-function call produces a new set of bindings. Closures from two factory calls can therefore run the same function code while owning independent state. Conversely, several functions returned by one outer call can refer to one binding and coordinate through it.

Definition site controls name lookup

The call site does not inject its local variables into a function. Even if the caller has a same-named variable, the closure resolves through the lexical chain at its definition site. A dynamically scoped language might search the call stack; JavaScript does not use that rule.

Global name lookup also follows the environment chain, but calling every function that reads a global a stateful closure rarely helps a code review. Start by marking the outer local bindings the function actually depends on, then identify which outer call created each one.

A function created dynamically with the Function constructor is an important boundary case. It does not capture the local scope that calls Function; it is created in the global scope. Do not use it to bypass ordinary lexical rules, and remember that it also raises code-injection and optimization concerns.

Reading, rebinding, and mutating objects

An inner function can reassign a captured let binding without an extra keyword. As long as the assignment target resolves to that binding through the lexical chain, later calls observe the update. If the inner function declares a same-named variable, that declaration shadows the outer binding.

const prevents reassignment; it does not freeze an object. After capturing const state = { count: 0 }, a closure can still execute state.count += 1. The object identity stays the same while one of its properties changes.

Review these two kinds of change separately. Reassignment changes the value currently associated with a name; object mutation is visible to every piece of code holding the same object reference. Generated code often calls both cases “captured state” and misses aliasing through a shared object.

Reachability controls lifetime

The outer function’s ordinary execution has ended after it returns, but bindings needed by a closure cannot disappear. While the closure remains reachable from a program root, objects referenced by those bindings may remain reachable too. Garbage collection follows reachability, not a signal that the outer function has returned.

This does not mean a closure must retain every local value from the whole outer call. Locals that the closure does not semantically use need not be kept for the closure. Review the object graph reached directly or indirectly by free variables, and identify who still holds the function.

Choosing a creation site by ownership

Where you create a function determines what it can capture, and the number of factory calls determines how many copies of state exist. Use these mappings as a quick design and review check.

Creation patternBinding ownerRelationship between later callsGood fit
Created once at module top levelModule instanceShared by every importerDeliberate in-process singleton state
Factory called per consumerEach factory callIsolated between consumersIndependent counters or configuration
One factory returns several methodsOne factory callShared between those methodsA narrow state-operation interface
Created per loop iterationEach lexical environmentLoop variable isolated between callbacksBulk callback registration

“Shared” in this table is not a value judgment. A metrics module may need shared state, while two components on one page usually should not share expanded state. Name the owner first, then decide where the function definition and factory call belong.

When ownership is hard to see from the creation site, use names that express scope for the factory and its result. createRequestCounter, sharedMetrics, and perUserHandler expose mistaken reuse more readily than makeThing or callback.

Closures in modules

An ES module has its own lexical environment. Functions defined and exported from the module can access module-level bindings, so those functions share state within the module instance. Importers cannot reassign an imported binding directly, but they observe updates made to that binding by the exporting module.

This sharing differs from the per-call state of a function factory. A module is normally instantiated and cached according to loader rules, so mutable top-level values often imply process- or page-wide sharing. Parallel tests, server request isolation, and hot reload can expose mistaken assumptions.

Modern code does not need an IIFE to imitate a module boundary. Use ES modules for file-private names, and export a factory only when the program needs several independent runtime instances. The creation count and ownership then remain visible.

An exported factory also lets each test create a fresh instance, avoiding mutable module state left behind by an earlier case. When a singleton is required, export it explicitly and put its reset policy at the test boundary.

A module-private name is still not a security vault. Exported functions can leak references, and logs, errors, or debugging tools may expose module state. The module boundary organizes access; it does not replace authorization, secret storage, or process isolation.

Examples

The four examples build from read-only configuration to mutable state, loop bindings, and explicit cleanup. Each one runs directly on Node 24, and the output below comes from the local execution.

Retaining read-only configuration

A function factory can turn one-time arguments into configuration for later calls. The free variables of formatPrice are currency and taxRate, and every call only reads them.

configured_formatter.js
function createPriceFormatter(currency, taxRate) {
  return function formatPrice(subtotal) {
    const total = subtotal * (1 + taxRate);
    return `${currency} ${total.toFixed(2)}`;
  };
}

const eurWithTax = createPriceFormatter("EUR", 0.2);
const usdWithTax = createPriceFormatter("USD", 0.08);

console.log(eurWithTax(50));
console.log(usdWithTax(50));
EUR 60.00
USD 54.00

The two calls to createPriceFormatter create different parameter bindings. eurWithTax and usdWithTax have the same function shape, but each resolves its own currency and taxRate. Callers do not need to pass those configuration values again.

The pattern is useful when you want to preconfigure a general operation behind a narrower interface. The rates here only demonstrate the arithmetic; they are not tax rules for real jurisdictions. Production code should obtain business data from validated configuration.

Sharing state within one factory call

One factory call can also return several operations. increment and read come from the same lexical environment, so they both access one count binding.

independent_counters.js
function createCounter(label) {
  let count = 0;

  return {
    increment(step = 1) {
      count += step;
      return `${label}:${count}`;
    },
    read() {
      return count;
    },
  };
}

const uploads = createCounter("uploads");
const retries = createCounter("retries");

console.log(uploads.increment());
console.log(retries.increment(3));
console.log(uploads.increment());
console.log(uploads.read(), retries.read());
uploads:1
retries:3
uploads:2
2 3

uploads.increment and uploads.read share the count created by the first call. retries comes from the second call, so calls to uploads do not affect its count. The interleaved output verifies that ownership boundary.

Callers cannot assign count through an ordinary property on the returned object, but this hiding is interface encapsulation, not a security boundary. Returned methods can still disclose state, and a debugger can observe execution. Do not rely on a closure alone to secure secrets such as access tokens.

If each consumer needs independent state, call the factory once for each consumer. Copying the returned object reference, registering one method in several places, or using object spread does not copy captured bindings.

Comparing var and let loop bindings

When callbacks run after a loop, whether bindings are separated by iteration determines their result. The two groups of arrow functions below have the same body; only the loop-variable declaration differs.

loop_bindings.js
const shared = [];
for (var index = 0; index < 3; index += 1) {
  shared.push(() => index);
}

const separate = [];
for (let index = 0; index < 3; index += 1) {
  separate.push(() => index);
}

console.log(shared.map((read) => read()).join(","));
console.log(separate.map((read) => read()).join(","));
3,3,3
0,1,2

The var declaration puts index in the enclosing function or global environment, and all three callbacks share that one binding. The loop has finished by the time they run, so the binding contains 3. Timers are not the root cause; calling the functions later and synchronously, as this example does, gives the same result.

A for loop declared with let creates a separate binding for each iteration that needs capture. The three callbacks therefore read 0, 1, and 2. for...of and for...in have corresponding per-iteration semantics when their iteration variable is declared with let or const.

If legacy code must keep var, call a factory that accepts the current value so that its parameter becomes a binding in a new call. Modern code is usually clearer with let.

Returning cleanup with registration

A long-lived event source holds its handler, and that handler holds its free variables. Returning a cleanup closure from the registration function keeps setup and teardown logic together.

subscription_cleanup.js
function subscribe(handlers, topic, listener) {
  function handle(message) {
    listener(`${topic}: ${message}`);
  }

  handlers.add(handle);
  return function unsubscribe() {
    handlers.delete(handle);
  };
}

const handlers = new Set();
const unsubscribe = subscribe(handlers, "build", console.log);

for (const handle of handlers) handle("passed");
console.log(`handlers=${handlers.size}`);
unsubscribe();
console.log(`handlers=${handlers.size}`);
build: passed
handlers=1
handlers=0

handle captures topic and listener. unsubscribe captures the same handle function object, so Set.delete receives exactly the identity used for registration. A newly created function with identical source would not match the original function’s identity.

Browser addEventListener and removeEventListener calls, as well as library subscribe and unsubscribe pairs, have similar identity requirements. Their exact option rules still need checking against their documentation; the Set here reduces the identity and lifetime relationship to a runnable example.

Pitfalls

Treating a binding as a value snapshot

This bug is not limited to var loops. If outer code declares let currentRequest and repeatedly reassigns it, every callback that captures it observes the same changing binding.

Fix: pass the value needed for this task into a factory parameter, or declare a new const snapshot in the current block before scheduling the callback. For an object, also decide whether you need shared identity, a shallow copy, or an immutable snapshot defined by the domain.

Reusing one stateful factory result

Giving a function another variable name does not create a closure, and copying an object containing methods does not copy the environment those methods capture. The bug can hide in dependency-injection configuration and bulk handler registration because every call site appears to have its own name.

Fix: call the factory inside the ownership boundary, once per independent consumer. Interleave calls to two consumers in a test and assert that one cannot change the other’s observable result. If sharing is intentional, put shared in the relevant name.

Treating closure privacy as a security boundary

An array not exposed as an object property is not automatically safe. Returning that array, returning a record that contains a secret, or interpolating the secret into an error message all cross the encapsulation boundary.

Fix: return a minimal redacted projection, make purpose-built copies of mutable collections, and enforce authorization and data minimization at real security boundaries. A name being inaccessible outside the closure is no reason to relax review of a secret’s lifetime.

Confusing closures with this

An arrow function has no own this and uses this from the surrounding execution context. That rule often appears beside closures, but it is not the same mechanism as resolving a named free variable. Generated code sometimes replaces every nested function with an arrow and changes APIs that need a dynamic receiver.

Fix: decide whether the callback needs lexical this or a receiver supplied by its caller. Use a saved wrapper or a single bind when fixing an instance; keep a regular function when the receiver must remain dynamic, and test through the real call path. See javascript/call-apply-bind for the receiver rules.

Forgetting to remove a long-lived registration

A cycle between closures is not automatically a leak; modern garbage collectors can reclaim an entirely unreachable cycle. The problem is a registration that remains reachable, such as a global event source holding a handler for an unmounted component.

Fix: retain the exact function identity used for registration and remove it when the component unmounts, the request ends, or the subscription is canceled. Capture the small value you need instead of a whole context just to obtain an ID, then use heap snapshots or lifecycle tests to confirm that teardown runs.

Creating one state container outside the loop

For example, handlers that all append to a history array declared before the loop get separate index bindings but one history. Blaming every loop problem on var misses this ownership error.

Fix: call a small factory for each consumer and create state that should be private inside that factory. List the free variables one by one and label each “shared” or “per instance”; this is more reliable than searching only for var.

Deep Bindings and environment records

Bindings and environment records

The language semantics describe name resolution with environment records. An environment record holds bindings in the current scope and links to an outer record; a function object remembers the environment where its lookup should begin. Engines may optimize this semantics with different data structures, so application code should not assume a particular stack-frame or heap-object layout.

“A closure captures a variable” is convenient shorthand, but a binding is more precise than the variable’s current value. If outer code creates a function after rate = 0.2 and later changes rate to 0.25, the next call reads the new value from that binding. Preserving the original requires placing it in a parameter binding from another function call or a new block binding.

Several functions created by one outer call can share an environment. The counter’s increment and read methods depend on that property: one updates count, and the other reads the same binding. Separate outer calls produce separate environments, letting a factory express per-instance state naturally.

Shadowing cuts off the outer lookup for a particular name. Once an inner function declares its own let count, count in that function refers to the new binding and no longer reaches the outer one. A review cannot identify bindings by searching for a variable name alone; declaration sites determine binding identity.

Object references introduce another layer of sharing. Two independent closures may capture different bindings that happen to hold the same object reference, in which case they still share changes to its properties. To judge isolation, trace environment bindings and object identity separately.

Asynchronous pauses do not freeze bindings

Declaring an inner function async does not change closure rules. After the function pauses at await, other code can run and reassign a captured binding. A later read of the free variable after resumption sees the binding’s value at that later time.

This can create a subtle race. An operation reads currentUser when a request begins, awaits a network response, and reads the same binding again after the signed-in user has changed. The two reads come from one closure but can produce different users.

If an operation must always belong to the entity that started it, create a local const owner = currentUser before the asynchronous work and use only owner afterward. This fixes the object reference; if other code can still mutate its properties, you also need a snapshot policy defined by the domain.

Some callbacks intentionally need the newest configuration, such as a retry that should use a recently updated backoff limit. In that case, a shared binding is part of the requirement and should not be copied mechanically. Names and comments should make the latest-value read deliberate.

A Promise does not copy the environment when a .then callback is created, and neither does a timer. They only arrange for a function to be invoked later. Keep lexical lookup and scheduling mechanics separate when reasoning about the result.

Control the pause point in tests for this code. Change the outer binding before allowing the operation to continue, then assert whether the callback should use the starting snapshot or the newest value. A success path without interleaving rarely exposes ownership errors.

Cancellation needs an ownership boundary too. If a closure captures an AbortController, retry count, and request owner, state whether they belong to one request or a shared client. Generated code often reuses one controller and accidentally cancels unrelated work together.

The eval and Function boundary

A direct eval call has complex interactions with the current execution context, and strict and non-strict modes differ. It also makes name dependencies difficult to analyze statically, so it should not be a normal closure-building tool. Use ordinary functions, objects, or data structures when they can express the behavior.

The Function constructor creates a function that runs in global scope and does not close over locals at its call site. Turning a string into a function neither reproduces the capture behavior of an ordinary nested function nor permits untrusted input safely. When the distinction matters, write a minimal case and run it on the target runtime.

Per-iteration environments

A traditional var loop has one loop-variable binding. Every arrow function retains a route to that same environment, so later calls read the value after the loop completes. Whether a timer, a Promise, or an array delays the callback does not alter the rule; delay only makes the final value easier to expose.

A classic for loop declared with let establishes a new per-iteration environment before moving to the next iteration and carries the needed loop-variable value into a new binding. A function created in the current iteration is associated with that iteration’s environment. The update expression works with the corresponding per-iteration binding, giving the example three different results.

The rule covers lexical bindings managed per iteration in the loop header. Objects outside the loop, module variables, and state outside a factory still follow their original sharing rules. let is not an automatic deep-copy switch, and it does not freeze the properties of the current iteration’s object.

A parameterized factory establishes the isolation boundary explicitly. Each helper call creates a new parameter binding, and its returned callback captures the parameter from that call. This is easier to name and test than an IIFE when maintaining older code; new code will usually choose let directly.

Reachability and teardown boundaries

The JavaScript specification defines observable behavior, not how an engine must lay out or reclaim closures. While a program can still call a function and observe its free variables, the engine has to preserve that behavior. Beyond that constraint, an optimizer can remove unobservable storage, so “a closure copies the whole scope onto the heap” is not a reliable model.

Trace a real retention chain from its long-lived owner. For example, a global event target points to a handler, the handler’s environment points to a component object, and the component points to a cache. As long as the initial registration exists, the later objects may remain reachable; the initialization function having returned is irrelevant.

A cleanup closure is useful because it retains the identity needed to break that relationship in the correct environment. It is not a garbage-collection API and does not force immediate memory release. Cleanup removes a strong reference created by the application; the runtime still decides when collection occurs.

Be cautious with performance conclusions. Whether closure creation is a bottleneck depends on call frequency, engine optimization, and captured objects; slogans such as “move arrow functions out of loops” cannot answer it. Find a real hotspot with the target runtime’s profiler before optimizing a path supported by measurements.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?