Closures, explained with a problem they solve
Closures have a reputation for being the hard bit. They are not hard; they are usually taught backwards — definition first, use later. So here is the problem first.
The problem
You need a counter. Order numbers for the day, say, starting at 1 and never repeating.
The obvious approach:
let orderNumber = 0;
function nextOrder() {
orderNumber += 1;
return orderNumber;
}
console.log(nextOrder());
console.log(nextOrder());
1
2
It works, and it has a real flaw: orderNumber is sitting at the top level where
anything can reach it. Any other code in your file — or any other file, later —
can write orderNumber = 500 or reset it to zero, and nextOrder cannot stop
them. The thing the counter depends on is the one thing it does not control.
What you want is a variable only nextOrder can touch.
The solution
Put it inside a function, and return the inner function:
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const nextOrder = makeCounter();
console.log(nextOrder());
console.log(nextOrder());
console.log(nextOrder());
1
2
3
Look at what just happened. makeCounter() ran and finished. Its local count
should be gone — and yet the returned function keeps counting up from where it
left off.
That is a closure: a function together with the scope it was created in. The
inner function still needs count, so count is kept alive for exactly as long
as that inner function exists. Nothing else in the programme can reach it. There
is no name for it outside makeCounter.
It follows directly from lexical scope, from the last lesson: what a name means
is decided by where the function was written. The inner function was written
inside makeCounter, so count is what it means, for good.
Each call makes a fresh one:
const counterA = makeCounter();
const counterB = makeCounter();
console.log(counterA(), counterA(), counterA());
console.log(counterB());
1 2 3
1
Two independent counters, two independent count variables. counterB is not
affected by counterA at all.
Private data, without classes
That pattern generalises. Here is a tiffin subscription with a balance nobody can edit directly:
function makeSubscription(startingBalance, ratePerPlate) {
let balance = startingBalance;
return {
deliver(plates) {
const cost = plates * ratePerPlate;
if (cost > balance) {
return 'Insufficient balance.';
}
balance -= cost;
return `Delivered ${plates}. ₹${balance} left.`;
},
check() {
return balance;
},
};
}
const priya = makeSubscription(500, 80);
console.log(priya.deliver(3));
console.log(priya.deliver(3));
console.log(priya.deliver(3));
console.log(priya.check());
console.log(priya.balance);
Delivered 3. ₹260 left.
Delivered 3. ₹20 left.
Insufficient balance.
20
undefined
priya.balance is undefined — there is no such property. The only ways to
change the balance are deliver, which checks first, and nothing else. You
cannot set it to a million, and you cannot make it negative.
This is what closures are actually for: state that is remembered between calls and cannot be reached from outside. Classes do something similar and arrive in module 5; closures got there first and are still the lighter tool.
The classic bug
This is the single most famous JavaScript interview question, and now you have everything needed to understand it.
const callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(function () {
return i;
});
}
console.log(callbacks.map((fn) => fn()));
[ 3, 3, 3 ]
Three functions, all returning 3. Nobody wanted that.
The reason is module 2's var leak meeting closures. var i is scoped to the
whole function, so all three closures captured the same i — not three copies
of a value, one shared variable. By the time any of them ran, the loop had
finished and that one variable held 3.
Change one word:
const callbacks = [];
for (let i = 0; i < 3; i++) {
callbacks.push(function () {
return i;
});
}
console.log(callbacks.map((fn) => fn()));
[ 0, 1, 2 ]
let creates a fresh binding for every iteration, so each closure captured
its own. This is a specific, deliberate rule for let in for loops, and it
exists precisely because the var version caught so many people.
You will meet this for real with setTimeout and with event handlers in a loop —
module 7 — where the functions genuinely run later. The fix is always the same:
use let.
Closures capture variables, not values
Worth being precise, because it explains the bug above rather than just patching it.
let rate = 80;
const showRate = () => rate;
rate = 95;
console.log(showRate());
95
showRate did not photograph rate when it was created. It kept a live
reference. Change the variable afterwards and the closure sees the new value.
That is usually what you want. It is occasionally a surprise, and when you need a snapshot, take a copy inside a function:
function freeze(value) {
return () => value;
}
let rate2 = 80;
const frozen = freeze(rate2);
rate2 = 95;
console.log(frozen());
80
value is a parameter — a fresh variable set once, at call time.
The cost
Closures are not free, and the honest version is this: a closure keeps its entire enclosing scope alive for as long as the function exists. If a closure captures one small number from a function that also held a large array, that array may be kept in memory too.
For the code you will write this is almost never a problem, and worrying about it early will make your code worse rather than faster. It becomes real in long-lived pages that create many closures — a handler attached on every render and never removed. Module 7 covers removing event listeners, which is the practical form this takes.
Check your work
makeCounter() returns a function that counts 1, 2, 3. count survives
because the returned function still refers to it, and it is unreachable from
anywhere else.
Two counters from makeCounter() are independent — 1 2 3 and then 1.
Each call creates a new scope with its own count.
priya.balance is undefined. The object returned exposes only deliver
and check; balance is a closed-over variable, not a property. That is the
privacy.
It is the third deliver(3) that fails, not the second. Three plates at ₹80
is ₹240. From ₹500: the first leaves ₹260, the second leaves ₹20, and the third
is refused because ₹240 is more than ₹20. If you assumed two deliveries would
exhaust ₹500 without doing the arithmetic, this is the cheapest possible place
to learn to check.
priya.balance = 100000 does not change the balance. check() still
returns 20. The assignment quietly adds a new, unrelated property called
balance to the returned object, which nothing reads. The closed-over variable
is untouched — and note that the assignment did not fail, it just had no effect,
which is the more dangerous outcome of the two.
The var loop gives [3, 3, 3]. All three closures share one i, which is
3 once the loop ends. With let it is [0, 1, 2], because let creates a new
binding each iteration.
showRate() after rate = 95 prints 95. Closures capture the variable,
not a copy of its value at creation time.
frozen() prints 80, because value is a parameter — a separate variable
assigned once when freeze was called.
makeRateCard has to hand back a copy of the history:
function makeRateCard() {
const history = [];
let current = null;
return {
set(rate) {
current = rate;
history.push(rate);
},
get() {
return current;
},
history() {
return [...history];
},
};
}
return history would look identical and be broken. An array is handed back by
reference, so a caller doing card.history().push(9999) would be pushing onto
your private array — closure or not. [...history] returns a fresh copy, and
pushing to that changes nothing. This is const protecting the binding and not
the contents, from module 2, showing up where it actually costs something.
Practice
- Build
makeCounterand call the returned function five times. - Create two counters from it and interleave their calls. Confirm they do not affect each other.
- Try to reach the inner
countfrom outside. Confirm you cannot, and say what that buys you. - Build
makeSubscriptionand deliver until the balance runs out. Predict which call fails before you run it — the arithmetic is the point. - Try
priya.balance = 100000and thenpriya.check(). Explain the result. - Write the
varloop bug. Get[3, 3, 3], then change one word and get[0, 1, 2]. Say out loud what the three closures were sharing. - Prove closures capture variables, not values: create a closure over a
let, change the variable, and call the closure. - Write
freeze(value)and show it does not change when the original does. - Harder. Write
makeRateCard()returning{ set, get, history }whereset(rate)records a new rate,get()returns the current one, andhistory()returns every rate ever set, in order. Nothing outside may modify the history — check that returning the array directly lets a caller push to it, and work out how to prevent that.
Next: this — the other thing everyone finds confusing, and the one arrow
functions were invented to fix.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship