
Introduction
Your loop runs 3 times but every callback logs 3. Not 0, 1, 2. Just 3, three 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 everywhere
const appName = "TaskTracker";
let userCount = 0;
function greetUser(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.
function calculateTotal(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 defined
tax 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";
function outer() {
const outerVar = "I'm from outer";
function inner() {
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.
function createCounter() {
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, the module pattern (an IIFE returning a public API) was one of the most common ways to organize browser code, alongside CommonJS in Node and AMD loaders. It is still a readable choice when you need 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 new, and without depending on how this gets bound at the call site, which is a common source of bugs.
Factory Functions
A function that returns another function. Specific behavior baked in.
function createMultiplier(factor) {
return function(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.
function memoize(fn) {
const cache = new Map();
return function(...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..." -> 100
expensiveCalc(10); // "Returning cached result" -> 100
expensiveCalc(7); // "Computing..." -> 49
The 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.
Each call to trackClicks creates a fresh clicks variable, and only that call's listener can see it:
function trackClicks(target, label) {
let clicks = 0; // private to this listener
target.addEventListener("click", () => {
clicks++;
console.log(`${label} clicked ${clicks} time(s)`);
});
}
// In the browser: trackClicks(document.querySelector("#save"), "Save");
// Any EventTarget works, so you can also try it in Node:
const save = new EventTarget();
const cancel = new EventTarget();
trackClicks(save, "Save");
trackClicks(cancel, "Cancel");
save.dispatchEvent(new Event("click")); // "Save clicked 1 time(s)"
save.dispatchEvent(new Event("click")); // "Save clicked 2 time(s)"
cancel.dispatchEvent(new Event("click")); // "Cancel clicked 1 time(s)"Debouncing
A debounce wrapper delays a function until calls stop arriving for a set time. Search boxes and resize handlers are the usual examples. The pending timer lives in the closure, so every call to the wrapper can cancel the previous one:
function debounce(fn, delayMs) {
let timerId; // survives between calls, hidden from outside code
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => fn.apply(this, args), delayMs);
};
}
const search = debounce((query) => {
console.log(`Searching for "${query}"`);
}, 300);
search("c");
search("cl");
search("clo"); // only this call runs, 300 ms after the last one
// Output: Searching for "clo"Two independent debounced functions get two independent timerId variables. Same factory, separate state.
Common Closure Pitfalls
The Classic Loop Problem
The bug that brought you to this article, probably:
// Buggy: all callbacks log 3
for (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 var
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// Output: 0, 1, 2
var 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 mutation
const config = { theme: "dark" };
config.theme = "light"; // This is fine
// config = {}; // TypeError: Assignment to constant
// Block scope demonstration
if (true) {
var a = 1; // leaks out of the block
let b = 2; // stays inside the block
const 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:
function createRateLimiter(maxCalls, windowMs) {
let calls = [];
return function(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);
return fn();
} else {
console.log("Rate limit exceeded. Try again later.");
return null;
}
};
}
// Allow 3 calls per 10 seconds
const 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 the one you are most likely to hit first. Build a private counter, a memoize function, and a debounce function. After that, closures stop being a concept and start being a reflex.