Closures
A closure is a function bundled together with references to the variables from the scope where it was defined. Because JavaScript functions remember their lexical environment, an inner function keeps access to its outer function’s variables even after that outer function has returned. Closures power everything from data privacy and factory functions to event handlers and module patterns — once you understand them, a huge amount of idiomatic JavaScript suddenly makes sense.
What a closure actually is
Every time a function is created, it captures the scope in which it was defined — not the scope from which it is later called. That captured scope stays alive as long as the inner function is reachable, even if the outer function has already finished executing and its call stack frame is gone.
function makeGreeter(greeting) {
// `greeting` lives in makeGreeter's scope
return function (name) {
return `${greeting}, ${name}!`;
};
}
const sayHi = makeGreeter("Hi");
const sayHola = makeGreeter("Hola");
console.log(sayHi("Ada"));
console.log(sayHola("Linus"));
Output:
Hi, Ada!
Hola, Linus!
makeGreeter has returned in both cases, yet the returned functions still see their own greeting. Each call to makeGreeter creates a fresh scope, so sayHi and sayHola close over independent values.
The classic counter
The canonical example of a closure is a counter that keeps state between calls without a global variable. The count variable is private to each counter instance — nothing outside can read or mutate it directly.
function createCounter(start = 0) {
let count = start;
return {
increment: () => ++count,
decrement: () => --count,
value: () => count,
};
}
const counter = createCounter(10);
console.log(counter.increment());
console.log(counter.increment());
console.log(counter.decrement());
console.log(counter.value());
Output:
11
12
11
11
All three returned methods close over the same count binding, so they share and mutate one piece of state. A second createCounter() call would produce an entirely separate count.
Private state and encapsulation
Closures are JavaScript’s oldest mechanism for true data privacy. Variables captured in a closure cannot be accessed from outside the function — there is no reference to reach them. This is the basis of the module pattern and a lightweight alternative to #private class fields.
function createBankAccount(initial) {
let balance = initial;
return {
deposit(amount) {
if (amount <= 0) throw new Error("Amount must be positive");
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) throw new Error("Insufficient funds");
balance -= amount;
return balance;
},
get balance() {
return balance;
},
};
}
const account = createBankAccount(100);
account.deposit(50);
account.withdraw(30);
console.log(account.balance);
console.log(account.balance === undefined); // no direct field access
Output:
120
false
There is no account.balance property storing a number — the getter reads the closed-over balance. The only way to change it is through the published methods, which enforce the rules.
The classic loop pitfall: var vs let
Before block scoping, the most common closure bug came from var in a loop. Because var is function-scoped, every closure created in the loop captured the same single variable, which by the time the callbacks ran held its final value.
// Buggy version with var
const fns = [];
for (var i = 0; i < 3; i++) {
fns.push(() => i);
}
console.log(fns.map((fn) => fn()));
Output:
[3, 3, 3]
let fixes this because it creates a new binding for each iteration. Each closure captures its own i.
const fns = [];
for (let i = 0; i < 3; i++) {
fns.push(() => i);
}
console.log(fns.map((fn) => fn()));
Output:
[0, 1, 2]
Always prefer
let/constin loops. Thevar-based bug is so common it was a primary motivation for introducing block scoping in ES2015.
The table below summarizes why the behavior differs.
| Aspect | var in loop | let in loop |
|---|---|---|
| Scope | One binding for the whole function | New binding per iteration |
| Captured value | Final value after loop ends | Value at that iteration |
| Result above | [3, 3, 3] | [0, 1, 2] |
| Fix needed | Wrap in IIFE per iteration | None |
If you are stuck with var, the historical workaround was an IIFE to create a fresh scope each iteration: (function (j) { fns.push(() => j); })(i);.
Closures and memory
Because a closure keeps its captured scope alive, variables referenced by a long-lived closure will not be garbage collected. This is usually exactly what you want, but holding onto large objects (or DOM nodes) inside a closure that outlives its usefulness can cause leaks. Release the closure (e.g. remove the event listener) when you no longer need it.
Best practices
- Reach for closures to encapsulate private state instead of leaking variables to a broader scope.
- Use
let/constin loops so each iteration captures its own binding — nevervar. - Return a small, intentional API from factory functions; expose only the methods callers need.
- Remember that closures capture variables, not snapshots — a later mutation is visible to every closure sharing that binding.
- Be mindful of memory: a closure pins its captured scope, so detach handlers and drop references you no longer use.
- Prefer factory functions or
#privateclass fields when they read more clearly than nested closures.