Free lesson · JavaScript Basic to Advance
Common Closure Mistakes and Memory Concerns
Where Closures Go Wrong
Closures are a powerful tool. They're also a source of specific bug patterns that have tripped up JavaScript developers for as long as the language has existed. This Topic covers the four mistakes I see most often in real codebases — and the memory implications of using closures at scale.
The bugs:
- The classic
varinforloop trap - Closing over the wrong value because of mutation
- Memory leaks from forgotten closures
- Stale closures in async code
The first one is the most famous. We've referenced it three times in earlier Topics. This is where we cover it definitively.
The var in for Loop Trap
The most famous closure bug in JavaScript:
console.log("With var:");
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(`var i is ${i}`), 10);
}
console.log("With let:");
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(`let j is ${j}`), 10);
}
When you run this, the var loop logs 3, 3, 3. The let loop logs 0, 1, 2. Same logic, drastically different results.
Here's why. With var, there is exactly one i for the entire loop — function-scoped, shared across iterations. By the time the setTimeout callbacks run (10 milliseconds later), the loop has finished and i is 3. All three callbacks read the same i, which is now 3.
With let, each iteration creates a new j in its own block scope. Each setTimeout callback closes over its own iteration-specific j. The three callbacks log 0, 1, 2 because those are the values their respective js held when the callbacks were created.
The trap isn't really about closures. It's about the difference between function scope and block scope (Topic 3.3), combined with the fact that closures capture references, not values (Topic 6.3). The two facts compound to produce the surprising behavior.
graph TD
A[for loop with var] --> B[ONE shared i<br/>function-scoped]
B --> C[All callbacks close<br/>over same i]
C --> D[By the time callbacks run,<br/>i has reached final value]
E[for loop with let] --> F[NEW j per iteration<br/>block-scoped]
F --> G[Each callback closes<br/>over its own j]
G --> H[Each callback sees<br/>its iteration's value]
style B fill:#dc2626,color:#fff
style F fill:#10b981,color:#fff
This diagram compares the two scoping behaviors. A for loop with var creates a single shared variable across all iterations because var is function-scoped. All callbacks close over the same variable, and by the time they run asynchronously, the variable has reached its final value. A for loop with let creates a new block-scoped variable per iteration, so each callback closes over its own iteration-specific value, producing the expected per-iteration results.
The fix is simple: use let instead of var in for loops. Before let existed, developers used workarounds like immediately-invoked function expressions (IIFEs) to manually create per-iteration scopes:
// Pre-let workaround (you'll see this in old code)
console.log("With IIFE workaround:");
for (var i = 0; i < 3; i++) {
(function(capturedI) {
setTimeout(() => console.log(`captured i is ${capturedI}`), 10);
})(i);
}
The IIFE creates a fresh function scope per iteration, with capturedI as a fresh parameter. Each callback closes over its own capturedI. This was a common pattern in pre-ES6 code; modern code just uses let.
Interactive animation: Three timers, one shared variable - the loop finishes first, so every callback logs 3.
Closing Over the Wrong Value
A variant of the same problem, without explicit loops:
let currentUser = "Olena";
const greetCurrentUser = () => console.log(`Hello, ${currentUser}`);
greetCurrentUser(); // "Hello, Olena"
currentUser = "Nikhil";
greetCurrentUser(); // "Hello, Nikhil"
The function captured a reference to currentUser, not a snapshot of its value. When currentUser changed, the captured reference now points to the new value.
This is sometimes what you want — sharing mutable state across functions, as we covered in Topic 6.4. But it's not always what you want. When you mean to capture a snapshot of a value, you have to copy it deliberately:
let currentUser = "Olena";
const captureAtCreation = (name => () => console.log(`Hello, ${name}`))(currentUser);
captureAtCreation(); // "Hello, Olena"
currentUser = "Nikhil";
captureAtCreation(); // still "Hello, Olena" — captured the value, not the reference
The IIFE pattern (name => () => ...)(currentUser) passes the current value of currentUser as the parameter name. The inner function closes over name, not over currentUser. Changes to currentUser don't affect what the inner function sees.
This pattern is useful when you specifically need value capture rather than reference capture.
Memory Leaks from Closures
Closures keep their captured scope alive. That's their entire purpose. But it also means closures can prevent garbage collection of things you didn't intend to keep alive.
function createBigClosure() {
const hugeData = new Array(1000000).fill("data"); // pretend this is expensive
const tinyValue = 42;
return () => tinyValue;
}
const fn = createBigClosure();
// 'hugeData' is still alive because the returned function's closure
// captured its enclosing scope — including hugeData,
// even though the returned function doesn't use it.
This is the subtle one. The returned function only uses tinyValue. It never references hugeData. But the JavaScript engine keeps the entire enclosing scope alive as long as the returned function exists — including hugeData, which is now wasted memory.
Modern JavaScript engines do some optimization here — they can sometimes detect that a closure doesn't reference certain variables and free them. But this optimization isn't guaranteed and isn't portable. Don't rely on it.
The fix, when this matters, is to explicitly avoid capturing large values you don't need:
function createBigClosureFixed() {
const hugeData = new Array(1000000).fill("data");
const tinyValue = 42;
// Process hugeData here, extract what's needed
const summary = hugeData.length;
// Return a function that doesn't need hugeData
return () => `${tinyValue} (from ${summary} items)`;
}
const fn = createBigClosureFixed();
// Now 'hugeData' can potentially be garbage collected
// because the returned function only references 'tinyValue' and 'summary'
For most code, this isn't a concern — closures are small, and they don't last long. But in code that creates many long-lived closures with large captured scopes (event handlers on persistent objects, for example), this matters.
Interactive animation: A tiny function pinning a huge scope - while it is reachable, everything it captured stays in memory.
Stale Closures in Async Code
A variant of "closing over the wrong value" that appears in async contexts:
async function staleClosureExample() {
let counter = 0;
function reportCounter() {
console.log(`Counter is ${counter}`);
}
reportCounter(); // 0
// Schedule the same report for later
setTimeout(reportCounter, 50);
counter = 100;
// By the time the setTimeout fires, counter is 100, not 0
}
staleClosureExample();
The first reportCounter() runs immediately and logs 0. The setTimeout schedules another call for 50 milliseconds later. By then, the line counter = 100 has executed, so the scheduled call logs 100.
This is the same closure mechanic — reference, not snapshot — but it surprises developers when it shows up in async code. The function "captured" a counter that has since changed.
The fix depends on what you actually want. If you want a snapshot, capture the value into a separate variable:
async function snapshotFix() {
let counter = 0;
const reportSnapshot = (capturedValue => () => {
console.log(`Snapshot counter was ${capturedValue}`);
})(counter);
setTimeout(reportSnapshot, 50);
counter = 100;
}
snapshotFix();
In React, this pattern shows up constantly with state — old closures referencing stale state values. React's useCallback and useRef hooks are partly designed to manage this. The underlying mechanism is the same closure behavior we've covered.
Interactive animation: The handler that saves yesterday - an old closure still holding a value the program has since replaced.
Common Pitfalls
Interactive animation: Attached forever means captured forever - a callback that is never removed keeps its captured scope alive.
Warning: The closure mistakes summarized.
varin loops with async callbacks. Useletinstead. The fix is one keyword. Don't writevarin new code.Expecting snapshot capture when reference capture happens. Closures capture variable bindings. If the variable changes, the closure sees the new value. To capture a snapshot, copy the value into a parameter via an IIFE.
Keeping closures alive on long-lived objects. Event handlers attached to elements that never get removed. Callbacks stored in long-lived registries. Each one keeps its captured scope alive. For long-running applications, this accumulates.
Forgetting that
setTimeoutcallbacks run later. The code beforesetTimeouthas run by the time the callback fires. Variables may have changed. Don't assume the world is the same.
Why This Matters in Interviews
Interview: The
varinforloop trap is one of the most common JavaScript interview questions. Interviewers love it because it tests three things at once: scope (function vs block), closures (reference capture), and the asynchronous model (callbacks run later). A clean answer touches all three.Memory implications and stale closures come up at senior levels, especially in interviews involving React or other frameworks where state and closures interact heavily.
Topic 6.7 — Interview Lens: Closures — covers the deeper treatment, including how to walk through these specific bugs under interview pressure.
Try It
Exercise: Predict the output of this code without running it, then run it to check:
const handlers = []; for (var i = 0; i < 3; i++) { handlers.push(() => console.log(i)); } for (let j = 0; j < 3; j++) { handlers.push(() => console.log(j)); } handlers.forEach(h => h());Six handlers, six logs. What does each one print, in order? Walk through it carefully — the first three handlers were created in the
varloop, the last three in theletloop. The values they remember reveal everything about how scope and closures interact.