Introduction
Your loop runs 5 times but every callback logs 5. Not 0, 1, 2, 3, 4. Just 5, five times. That is closures.
Or rather, that is closures going wrong. When they go right, you get data privacy, factory functions, memoization, and half of how React hooks work under the hood. The concept itself is small. The confusion comes from bad explanations that bury it under scope theory you already know.
Understanding Scope in JavaScript
Scope controls where a variable is visible. Global, function, block. If you already know the difference between var, let, and const, skip to the closures section.
Global Scope
Variables declared outside any function or block. Available everywhere. Convenient until two files define the same variable name and silently clobber each other.
// Global scope - accessible everywhereconst appName = "TaskTracker";
let userCount = 0;
functiongreetUser(name) {
// Can access appName from inside a function
console.log(`Welcome to ${appName}, ${name}!`);
userCount++;
}
greetUser("Alice");
console.log(userCount); // 1appName and userCount are global. greetUser reads and modifies both. Fine for a 50-line script. Disaster in anything larger.
Function Scope
Variables inside a function die when the function finishes. Or they should. Closures complicate that.
functioncalculateTotal(price, taxRate) {
const tax = price * taxRate;
const total = price + tax;
return total;
}
const result = calculateTotal(100, 0.08);
console.log(result); // 108
console.log(tax); // ReferenceError: tax is not definedtax does not exist outside calculateTotal. Locked in. No name collisions with the rest of your code.
Block Scope
let and const are block-scoped. Anything between curly braces. var ignores blocks entirely -- only respects function boundaries. This one distinction is at the heart of the most common closure bug.
The Scope Chain and Lexical Environment
Variable lookup goes: current scope, then enclosing scope, then the next one up, all the way to global. Not found anywhere? ReferenceError.
The scope chain is fixed by where the function is written in the source code, not where it gets called. That is "lexical scope." JavaScript wires this up at parse time. Each function carries a reference to the environment where it was created, and that reference persists even after the outer function finishes executing. Which is the whole mechanism behind closures.
const globalVar = "I'm global";
functionouter() {
const outerVar = "I'm from outer";
functioninner() {
const innerVar = "I'm from inner";
// inner can see all three variables
console.log(innerVar); // "I'm from inner"
console.log(outerVar); // "I'm from outer"
console.log(globalVar); // "I'm global"
}
inner();
// outer cannot access innerVar// console.log(innerVar); // ReferenceError
}
outer();When inner() runs, JavaScript checks inner's own scope first, then outer's, then global. Fixed by source code structure. Pass inner as a callback anywhere else in the application and it still resolves variables through the same chain.
What Closures Really Are
A function that retains access to variables from the scope where it was created, even after that scope has finished executing.
That is the entire definition. Everything else is examples.
The inner function carries a live reference to the outer scope's variables. Not a snapshot. Not a copy. A reference. The outer function leaves the call stack, but the inner function still reads and writes those variables as if nothing happened.
functioncreateCounter() {
let count = 0;
return {
increment() {
count++;
return count;
},
decrement() {
count--;
return count;
},
getCount() {
return count;
}
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1
console.log(counter.getCount()); // 1// count is not directly accessible
console.log(counter.count); // undefinedcreateCounter() finishes. Its local variable count should be garbage collected. But increment, decrement, and getCount all close over it, keeping it alive. They share the same count. And outside code cannot touch it at all -- counter.count returns undefined. Genuine data privacy without classes, without private fields, without any ceremony. Just a function inside a function.
Practical Closure Patterns
Data Privacy and Encapsulation
The counter above is the basic shape. Closures give you modules with a public API and hidden state. Before ES modules, this was how all JavaScript code got organized, and honestly? I still find the revealing module pattern more readable than ES6 classes when all I need is one object with private internals. Classes nudge you toward instances and inheritance hierarchies. Sometimes you just want a thing that manages its own state. Closures do that without the overhead of new and without the confusion around this binding that trips up even experienced developers who should probably know better by now but keep getting bitten anyway because this in JavaScript is genuinely awful.
Factory Functions
A function that returns another function. Specific behavior baked in.
functioncreateMultiplier(factor) {
returnfunction(number) {
return number * factor;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
const toPercent = createMultiplier(100);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(toPercent(0.42)); // 42Each function closes over a different value of factor. Same factory, different captured argument. This pattern shows up everywhere -- Lodash, Ramda, Redux middleware.
Memoization
If you have seen this pattern before, skip ahead. Cache expensive results so you do not recompute them.
functionmemoize(fn) {
const cache = newMap();
returnfunction(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log("Returning cached result");
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const expensiveCalc = memoize(function(n) {
console.log("Computing...");
return n * n;
});
expensiveCalc(10); // "Computing..." -> 100expensiveCalc(10); // "Returning cached result" -> 100expensiveCalc(7); // "Computing..." -> 49The cache Map lives inside the closure. Outside code cannot read it, clear it, or tamper with it. Build this once, reuse it everywhere.
Event Handlers and Callbacks
Every event listener you attach inside a function closes over surrounding variables. Why closures matter for event-driven JavaScript. Also why they trip people up.
Common Closure Pitfalls
The Classic Loop Problem
The bug that brought you to this article, probably:
// Buggy: all callbacks log 3for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// Output: 3, 3, 3 (not 0, 1, 2!)// Fix: use let instead of varfor (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// Output: 0, 1, 2var is function-scoped. One i. Shared across every iteration. By the time the callbacks fire, the loop is done and i is 3. All three closures point at the same variable.
And the fix is just let. Because let creates a new binding per iteration, each callback gets its own copy. Each closure captures a different i. That is the whole fix. One keyword.
Memory Leaks
Closures keep variables alive. A closure holding a reference to a large object? That memory stays allocated as long as the closure exists.
In SPAs this gets ugly fast. A setInterval callback closing over a large dataset, set up in a useEffect without cleanup -- every remount spawns a new interval, each holding its own reference. Memory climbs. Users notice. The fix: clean up. Clear intervals. Null out references. Be deliberate about what your closures capture.
Stale Closures in React
A useEffect with an empty dependency array captures state from the initial render. State updates. The effect callback keeps reading the old value. Classic stale closure.
Fixes: include the variable in the dependency array, use functional state updates (setCount(prev => prev + 1)), or stash the latest value in a ref. Not a React bug. Just closures doing what closures do, surfaced by React's render model.
var vs let vs const and Hoisting
var is function-scoped. Hoisted to the top of its function. Reference it before the declaration line and you get undefined, not an error. Block boundaries invisible to it.
let and const are block-scoped. Sit in a "temporal dead zone" until the declaration. Access too early? ReferenceError. Louder failure. Easier debugging.
const prevents reassignment but not mutation. Assign an object to a const, you can still change its properties. The binding is constant. The value is not.
// var hoisting in action
console.log(x); // undefined (hoisted, not an error)var x = 10;
// let temporal dead zone// console.log(y); // ReferenceError!let y = 20;
// const prevents reassignment, not mutationconst config = { theme: "dark" };
config.theme = "light"; // This is fine// config = {}; // TypeError: Assignment to constant// Block scope demonstrationif (true) {
var a = 1; // leaks out of the blocklet b = 2; // stays inside the blockconst c = 3; // stays inside the block
}
console.log(a); // 1// console.log(b); // ReferenceError// console.log(c); // ReferenceErrorvar in a loop: one variable, all closures share it. let in a loop: fresh variable per iteration, each closure gets its own. Root cause of the loop bug.
Use const by default. let when you need reassignment. Never var. Some people argue var has niche uses for intentional hoisting. Every time I have seen var in modern code it was legacy or a bug. Drop it entirely and you eliminate a whole category of closure problems before they start.
A closure captures the variable, not its value at a point in time. That is why closures see updates even after creation, and why
varin loops causes trouble. One variable, every closure watching the same one change.
One more. A rate limiter that ties scope, closures, and something actually useful together:
functioncreateRateLimiter(maxCalls, windowMs) {
let calls = [];
returnfunction(fn) {
const now = Date.now();
// Remove calls outside the time window
calls = calls.filter(time => now - time < windowMs);
if (calls.length < maxCalls) {
calls.push(now);
returnfn();
} else {
console.log("Rate limit exceeded. Try again later.");
returnnull;
}
};
}
// Allow 3 calls per 10 secondsconst limited = createRateLimiter(3, 10000);
limited(() => console.log("Call 1")); // "Call 1"limited(() => console.log("Call 2")); // "Call 2"limited(() => console.log("Call 3")); // "Call 3"limited(() => console.log("Call 4")); // "Rate limit exceeded..."The calls array is hidden. Outside code cannot reset it, tamper with it, or bypass the limit. And you can create multiple independent rate limiters from the same factory. Each one gets its own calls array. Its own limits. Completely isolated state.
Conclusion
When a closure misbehaves there is only one question: which variable is this function closing over, and is it the one I think it is? The loop bug, stale hooks, accidental shared state -- same underlying issue.
Closures connect to a bunch of other things -- prototypal inheritance, the module pattern, React hooks, currying. But the loop problem is 90% of what you will actually debug. Build a private counter, a memoize function, and a debounce function. After that, closures stop being a concept and start being a reflex.