Closures in loops, timers & memory
Predict and fix the classic closure traps: var in loops, timers that run later, stale callbacks, and long-lived functions that keep memory reachable.
- 01Predict timer loopsExplain why var prints 3, 3, 3 while let prints 0, 1, 2.
- 02Fix stale callbacksChoose a fresh binding, a getter, or a recreated callback deliberately.
- 03Avoid accidental retentionSpot closures that keep large objects, listeners, or timers alive.
The problem closures create
Closures are not a rare advanced trick. Every function you create keeps access to the place where it was created. That power is why callbacks, factories, private state, memoize caches, and event handlers work.
The surprise is that a closure remembers bindings, not a frozen picture of every value. If the function runs later, the binding it reads may have changed. If the app replaced state with a new object, the closure may still read the old object. If a function lives for a long time, anything it still reaches can stay in memory too.
When a closure surprises you, ask: which binding does this function read when it actually runs? Then ask whether that binding keeps anything large reachable.
The Closures lesson introduced the idea that closures retain bindings, not snapshots. This lesson goes deeper into the classics you will debug in real code: loop callbacks, timers, stale callbacks, and accidental memory retention.
var vs let in loops
STEP THROUGHThe famous closure puzzle is small enough to fit on three lines. With var, the callbacks print 3, 3, 3. With let, they print 0, 1, 2. The callbacks are not different; the binding named i is different.
Imagine every timer looking at a whiteboard after class. If the loop used var, there is one shared whiteboard, and by the time anyone looks it says 3. If the loop used let, each timer gets its own sticky note: 0, 1, and 2.
- In real life: One classroom whiteboard
- In JavaScript: one shared
var ibinding - In real life: The final number left on the board
- In JavaScript: the loop finishes with
i === 3 - In real life: A sticky note for each student
- In JavaScript: a separate
let ibinding per iteration
Where the analogy stops: A real sticky note copies ink. JavaScript is more precise: each let iteration has its own binding, and the closure reads that binding later.
Run the replay twice. First leave it on var and watch the one binding change. Then switch to let and notice that each scheduled callback points at its own iteration binding.
Predict the console output. Timers run later, after the synchronous loop has already finished.
script
setTimeout(() => console.log(i), 0);}setTimeout runs later
REAL OUTPUTThe full Timers and Event loop lessons arrive in Stage 6. For today, keep one simple rule: setTimeout asks the host to call a function later. JavaScript continues running the current code. A loop finishes before those timer callbacks run.
setTimeout(fn, 0) does not pause the loop. It schedules fn for after the current synchronous work. That is why every var timer sees the final loop value.
The callback is created during the loop. The console.log inside it runs later. This timing difference is the whole trick.
Four reliable loop fixes
INTERACTIVEGood fixes give each callback a fresh binding or pass it the current value. Modern code usually uses let in the loop header. Older code may use an IIFE. Timer code can pass extra arguments to setTimeout. Array iteration methods such as forEach also give each callback call its own parameter.
| Fix | Idea | When you will see it |
|---|---|---|
let in the loop header | Fresh per-iteration binding | Modern JavaScript; prefer this for ordinary loops |
| IIFE | Copy the current value into a function parameter immediately | Legacy code before block-scoped let was common |
setTimeout extra argument | Pass i into the callback as n | Timer-specific code that still uses var |
forEach parameter | Each callback call gets its own parameter binding | Array iteration where indices or values are already items |
This old-school fix keeps var, but passes the current value as a callback argument.
script
setTimeout((n) => console.log(n), 0, i);}for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);for (var i = 0; i < 3; i++) ((n) => setTimeout(() => console.log(n), 0))(i);const fns = []; for (var i = 0; i < 3; i++) fns.push(() => console.log(i)); fns.forEach((fn) => fn());[0, 1, 2].forEach((i) => setTimeout(() => console.log(i), 0));for (var i = 0; i < 3; i++) setTimeout((n) => console.log(n), 0, i);
Place each loop variant by the output it really prints.
Stale closures
TICKING DEMOA stale closure is a callback that reads something that was accurate when the callback was created, but is no longer the current source of truth. This happens a lot in UI code because apps often replace state objects instead of mutating the same object.
A scoreboard photo can be true and outdated at the same time. Stale closures feel similar: the callback is faithfully reading the thing it captured, but the app has moved on.
- In real life: The score when the photo was taken
- In JavaScript: the object captured when the callback was created
- In real life: The live scoreboard
- In JavaScript: the current state binding
- In real life: Checking the old photo later
- In JavaScript: a callback reading stale data
Where the analogy stops: A stale closure is not literally a photo. It is a function reading an old binding or object that your current code no longer updates.
In this plain JavaScript example, settings is replaced. The old callback reads captured, which still points at the original object.
A stale closure is not time travel. It is a callback reading a binding or object that your current app no longer updates.
script
function startTicker() { const captured = settings; return () => console.log(captured.label);} const tick = startTicker();settings = { label: "fast" };tick();let settings = { label: "slow" };
function startTicker() {
const captured = settings;
return () => console.log(captured.label);
}
const tick = startTicker();
settings = { label: "fast" };
tick();- Current settingsslowPreferred delay label700ms
- Modestale captureStatusstopped
No ticks yet.
Start the ticker, then replace settings. Stop clears the interval so no old callback keeps running.
Frameworks with re-rendered functions, including React hooks, hit stale closures often. The fix is framework-specific, but the core JavaScript idea is the same: decide whether the callback should read an old binding, a fresh getter or reference, or be re-created when the value changes.
Closures that hold memory
REACHABILITYThe Memory: primitives vs objects lesson introduces reachability and garbage collection basics. A value can be collected only when nothing reachable points to it. A long-lived closure is reachable through the function object, so values it closes over can remain reachable too.
A tiny function can feel harmless, like an empty backpack. But if the backpack still contains a heavy object, carrying the backpack keeps the heavy object with you.
- In real life: A backpack you keep wearing
- In JavaScript: a long-lived returned function
- In real life: A heavy book inside
- In JavaScript: a large array or object the closure can still reach
- In real life: Taking out one note
- In JavaScript: copying only
rows.lengthintorowCount
Where the analogy stops: Engines can optimize what is retained, so do not treat this as a heap diagram. Use it as a code-review habit: avoid keeping a path to large data you no longer need.
function makeReport() {
const rows = new Array(1_000_000).fill("row");
return () => rows.length;
}
const report = makeReport();
console.log(report());- Closure carriesthe returned function reads rows.lengthMemory consequencethe rows array can remain reachablePrinted outputnot run yet
The tiny returned function can keep the huge array reachable because it reads rows.length.
| Pattern | Why memory stays reachable | Practical fix |
|---|---|---|
| Timer callback | A repeating interval keeps its callback reachable. | Call clearInterval when the work is no longer needed. |
| Event listener | The target keeps the listener function, and the listener keeps its closed-over values. | Remove the listener, or use { once: true } / an abort signal. |
| Large captured object | A returned tiny function can keep a big array or DOM subtree reachable. | Copy only the needed value, such as rows.length. |
| Unbounded cache | Memoize closures intentionally retain results. | Use a size limit or WeakMap when keys are objects. |
function makeReport() {
const rows = new Array(1_000_000).fill("row");
return () => rows.length;
}
const report = makeReport();
console.log(report());function makeReport() {
const rows = new Array(1_000_000).fill("row");
const rowCount = rows.length;
return () => rowCount;
}
const report = makeReport();
console.log(report());When you intentionally keep a cache in a closure, make the retention a design choice: size-limit it, expose a clear method, or use WeakMap when object keys should not be kept alive by the cache itself. The later Finding memory leaks lesson shows heap snapshots for proving these chains in real applications.
Where you will use this
You will use these rules whenever code runs later than where it was written: timers, intervals, event listeners, animation callbacks, subscriptions, retries, and UI handlers. Professional debugging often means drawing two timelines: when the callback was created, and when it actually ran.
function startPolling(readUrl) {
const id = setInterval(() => {
console.log("poll", readUrl());
}, 1000);
return () => clearInterval(id);
}The returned cleanup function closes over the timer id on purpose. The interval callback reads the URL through a function, so it can stay fresh. The cleanup closes the reachability chain when polling stops.
Common misconceptions
- “Closures copy values.” They retain access to bindings. A changed binding can be read later.
- “setTimeout with 0 runs immediately.” It still runs after the current synchronous code finishes.
- “const prevents stale closures.”
constprevents reassignment of that binding; it does not freeze an object or refresh old callbacks. - “let makes callbacks synchronous.”
letfixes the binding identity, not the timer schedule. - “Garbage collection frees anything I am not using mentally.” It frees unreachable values. A closure, listener, timer, or cache can keep a path alive.
Practice exercises
5 EXERCISESPredict the three console lines.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}The loop finishes with i equal to 3. Every timer callback reads that same binding, so the output is 3, 3, 3.
Change the classic loop so it prints each value. What output should this fixed version produce?
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}let in the for header gives each callback its own iteration binding, so the timers print 0, 1, 2.
Use the timer-specific fix and predict its output.
for (var i = 0; i < 3; i++) {
setTimeout((n) => console.log(n), 0, i);
}for (var i = 0; i < 3; i++) {
setTimeout((n) => console.log(n), 0, i);
}The third argument to setTimeout is passed into the callback as n, so the callback logs the value from that iteration.
Refactor the stale closure so the callback reads the current settings object. What does it print?
let settings = { label: "slow" };
function startTicker(readSettings) {
return () => console.log(readSettings().label);
}
const tick = startTicker(() => settings);
settings = { label: "fast" };
tick();let settings = { label: "slow" };
function startTicker(readSettings) {
return () => console.log(readSettings().label);
}
const tick = startTicker(() => settings);
settings = { label: "fast" };
tick();The callback calls readSettings() when it runs, so it reads the current settings binding after it has been replaced.
Return the same count without making the closure depend on the whole array.
function makeReport() {
const rows = new Array(1_000_000).fill("row");
const rowCount = rows.length;
return () => rowCount;
}
const report = makeReport();
console.log(report());function makeReport() {
const rows = new Array(1_000_000).fill("row");
const rowCount = rows.length;
return () => rowCount;
}
const report = makeReport();
console.log(report());The closure reads rowCount, not the rows array, so it can report the same number without needing the huge object.
Quiz
7 QUESTIONSQuestion 1 of 7What does this print?
Read the code, then predictconst callbacks = []; for (var i = 0; i < 3; i++) { callbacks.push(() => console.log(i)); } callbacks[0]();Choose an answer to see the explanation.
Question 2 of 7Why does
letfix the classic timer loop?Choose an answer to see the explanation.
Question 3 of 7What does this stale-closure example print?
Read the code, then predictlet settings = { label: "slow" }; const captured = settings; settings = { label: "fast" }; console.log(captured.label);Choose an answer to see the explanation.
Question 4 of 7Which stale-closure fix keeps a callback fresh without recreating it?
Choose an answer to see the explanation.
Question 5 of 7What does the report factory print?
Read the code, then predictfunction makeReport() { const rows = new Array(3).fill("row"); const rowCount = rows.length; return () => rowCount; } console.log(makeReport()());Choose an answer to see the explanation.
Question 6 of 7Which cleanup prevents a closure from staying alive forever?
Choose an answer to see the explanation.
Question 7 of 7Which statement is most accurate?
Choose an answer to see the explanation.
Key takeaways
varin a loop gives callbacks one shared binding;letin the loop header gives each iteration its own binding.setTimeoutcallbacks run later, after the current loop or function has finished.- A stale closure reads an old binding or object that your current code no longer updates.
- Long-lived closures can keep values reachable; copy only what you need and clean up timers and listeners.
A closure pitfall is a correct memory of the wrong thing: the wrong binding, the wrong time, or a value that should no longer be kept alive.
Up next: The old var & the global object.