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.

JavaScript
// 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); // 1

appName 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.

JavaScript
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 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.

JavaScript
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.

JavaScript
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); // undefined

createCounter() 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.

JavaScript
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)); // 42

Each 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.

JavaScript
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..." -> 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.

Common Closure Pitfalls

The Classic Loop Problem

The bug that brought you to this article, probably:

JavaScript - The Bug
// 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, 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.

JavaScript
// 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); // ReferenceError

var 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 var in 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:

JavaScript - Putting It All 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.

Ravi Krishnan

Ravi Krishnan

Backend Engineer & Systems Programmer

Ravi is a backend engineer and systems programmer with deep expertise in Rust, Go, and distributed systems. He writes about performance optimization and scalable architecture.