JavaScript Closures Explained, With Examples
A JavaScript closure is a function that remembers the variables from where it was defined. How closures work, why they matter, and the classic loop gotcha.
A closure is a function bundled together with the variables that were in scope where the function was defined. When a function is created inside another function, the inner function keeps access to the outer function’s variables — even after the outer function has finished running and returned. That “remembering” of the surrounding scope is the closure. It is one of the most-used features in all of JavaScript, and one of the most misunderstood.
Closures fall out naturally from two facts about the language: functions are values you can pass around and return, and every function carries a reference to the scope it was born in. Put those together and you get functions that hold onto state.
A minimal example
Here is the canonical counter:
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const next = makeCounter();
next(); // 1
next(); // 2
next(); // 3
makeCounter runs once and returns. Normally you would expect its local variable count to vanish when it returns. It doesn’t. The returned function still references count, so JavaScript keeps that variable alive. Each call to next() reads and updates the same count. Call makeCounter() again and you get a fresh, independent count — each closure has its own private copy of the scope.
That is the whole idea. The rest is consequences.
Why closures matter
Closures are the mechanism behind several patterns you use constantly, whether or not you name them.
- Data privacy. Before the language had
#privateclass fields, closures were the standard way to hide state. Variables inside the outer function are unreachable from outside — only the functions you return can touch them. The counter above has no way for outside code to setcountto 500. - Factory functions.
makeCounteris a factory: call it with different arguments and each call produces a function pre-configured with those arguments captured in its closure. - Callbacks and event handlers. When you pass a function to
addEventListener,setTimeout, or an array method, it usually references variables from the surrounding scope. That reference is a closure. It is why an event handler can still “see” the element or the piece of state it was set up with. - Stable references over time. Utilities like debounce and throttle rely on closures to hold a timer ID or a “last called” timestamp between invocations. Our piece on debounce vs throttle is closures in action — the returned wrapper remembers a timer across calls.
The classic loop gotcha
The most famous closure bug comes from loops. Consider:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// logs 3, 3, 3
People expect 0, 1, 2. They get 3, 3, 3. The reason: with var, there is a single i shared by every iteration. All three arrow functions close over that same variable. By the time the timeouts fire, the loop has finished and i is 3. The closures faithfully report the one and only i, which is now 3.
The fix is a per-iteration variable. Swapping var for let does exactly that:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// logs 0, 1, 2
let creates a new binding for i on each iteration, so each closure captures a different one. This single behavior is one of the strongest everyday reasons to prefer let and const over var. Understanding why it happens also means understanding that closures capture variables, not values — they hold a live reference to a binding, not a frozen snapshot taken at creation time.
Closures capture references, not copies
That last point deserves its own example, because it trips up even experienced developers:
let message = "hello";
const say = () => console.log(message);
message = "goodbye";
say(); // "goodbye"
say doesn’t print "hello" even though message was "hello" when the function was defined. The closure captured the variable message, not its value at that moment, so it reads whatever message holds when it finally runs. This is powerful — it is how the counter shares state — but it means you should be deliberate about mutating captured variables.
Closures and the event loop
Closures also explain a lot of asynchronous behavior. When an async callback finally runs — after a network request, a timer, or a user click — the code that scheduled it has long since finished. The callback works because its closure preserved everything it needs. This is why our explainer on the JavaScript event loop and closures reinforce each other: the event loop decides when deferred code runs, and closures guarantee that code still has the context it needs when that moment arrives.
A note on memory
Because a closure keeps its captured variables alive, it can extend the lifetime of objects that would otherwise be garbage-collected. Usually this is exactly what you want. Occasionally it causes a leak: an event handler that closes over a large object, attached and never removed, keeps that object in memory as long as the handler lives. The remedy is ordinary hygiene — remove listeners you no longer need, and don’t capture large structures in long-lived closures without reason. In practice this is a rare problem, not a reason to avoid closures.
The takeaway
A closure is a function plus the scope it was defined in, letting inner functions remember outer variables long after the outer function returns. Closures power private state, factory functions, callbacks, and utilities like debounce. The one rule to internalize: closures capture variables by reference, not by value — which is exactly why the classic var loop prints the final value three times and why let, with its per-iteration binding, fixes it. If you are still building fundamentals, our overviews of what JavaScript is and getting started with TypeScript pair well with this one.
Tagged
Keep reading
Takina · · 5 min read Promise.all() vs allSettled() vs race() Compared
Promise.all() fails fast, allSettled() waits for every result, and race() returns whichever promise finishes first — how to choose correctly.
Takina · · 4 min read JavaScript Spread vs Rest Operators, Explained
The spread operator (...) expands an iterable into individual elements; the rest operator collects elements back into an array. Same syntax, opposite jobs.
Takina · · 4 min read ResizeObserver API Explained: Watching Element Size
The ResizeObserver API lets JavaScript watch an element's box size and react without polling or resize-event hacks. How it works and when to use it.