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 a loop with var prints 3, 3, 3, and the same loop with let prints 0, 1, 2.
- 02Fix callbacks that read old dataGive a callback a new variable, a function that returns the current data, or a fresh callback.
- 03Stop keeping memory by mistakeSpot functions that keep big objects in memory, and stop timers and listeners you no longer need.
When closures surprise you
A closure is a function together with the variables around the place where it was made. Every function you write is a closure. It can still use those outside variables later, even after the outer code has finished.
This is what makes many everyday tools work:
- callbacks, which are functions you hand to other code to call later,
- functions that make other functions,
- private data that only a few functions can change,
- caches, which keep saved results so a function does not work them out twice,
- event handlers, which run when the user clicks or types.
The surprise is this: a closure keeps the variable itself, not a copy of its value. The spec calls such a variable a binding: a name joined to a value. Three things can go wrong:
- The function runs later, and the variable has changed by then.
- The app put a new object in a different variable, but the function still reads the old object.
- The function stays around for a long time, so everything it uses stays in memory too.
When a closure surprises you, ask: which variable does this function read at the moment it runs? Then ask a second question: does that variable keep something big in memory?
The Closures lesson showed that a closure keeps variables, not copies of values. This lesson shows the four places where that causes real bugs: callbacks made in loops, timers, callbacks that read old data, and memory that is kept by mistake.
var and let in a loop
STEP THROUGHThe best-known closure puzzle fits in three lines. The loop asks for three timers, and each timer prints i. With var, the timers print 3, 3, 3. With let, they print 0, 1, 2. The timer functions are the same in both. The difference is how many variables named i there are.
A teacher writes 0, then 1, then 2, then 3 on one whiteboard, rubbing out the old number each time. Three students look at the board after class. They all read 3. Now the teacher gives each student a sticky note instead, with the number at that moment: 0, 1, and 2. Each student reads their own note.
- In real life: One classroom whiteboard
- In JavaScript: one shared
var i - In real life: The last number written on the board
- In JavaScript: the loop ends with
iequal to 3 - In real life: A sticky note for each student
- In JavaScript: a new
let ifor each loop step
var, the loop has one box called i. All three timers read it after the loop, when it holds 3. With let, each loop step gets a new box, so each timer reads its own number.Run the replay below twice. First leave it on var and watch the one i change. Then switch to let. Each timer now points at its own i from its own loop step.
Predict the console output. The timers run later, after the loop has already ended.
script
setTimeout(() => console.log(i), 0);}setTimeout runs later
REAL OUTPUTYou will learn timers fully in Stage 6, in the Timers and Event loop lessons. For now, one rule is enough:
setTimeout(fn, delay)asks the browser (or Node.js) to callfnlater.- JavaScript does not wait. It goes on with the next line straight away.
- So a loop always finishes before any of its timer functions run.
setTimeout(fn, 0) does not pause the loop. It saves fn to run after all the code that is running now has finished. That is why every var timer sees the last loop value.
The timer function is made during the loop. The console.log inside it runs later. That gap in time is the whole trick.
console.log("before");
setTimeout(() => console.log("timer"), 0);
console.log("after");Line by line:
- Line 1 prints
beforestraight away. - Line 2 hands a function to
setTimeout. The function waits until the script is done. - Line 3 prints
afterstraight away.
Only then does the timer function run and print timer. A delay of 0 milliseconds means "as soon as possible after this code", not "now".
setTimeout saves the function, and the function runs only after lines 1 to 3 have finished.If a callback prints a value you did not expect, add two logs. Put one where the callback is made, and one inside the callback. The two messages show you the gap between "made" and "ran".
Four ways to fix the loop
INTERACTIVEEvery good fix does one of two things. It gives each callback its own new variable, or it hands the callback the value as it is right now. Here are the four fixes you will meet:
letin the loop header. This is what modern code uses.- An IIFE. IIFE is short for "Immediately Invoked Function Expression": a function that is called right where it is written. Older code uses it.
- An extra argument to
setTimeout. Values you write after the delay are passed into the timer function when it runs. forEach. This array method calls your function once per item, and each call gets its own parameter.
The table puts the four fixes side by side. Read each row from left to right: the fix, why it works, and where you will see it.
| What you write | Why it works | Where you will see it |
|---|---|---|
let in the loop header | Each loop step gets a new i. | Modern JavaScript. Use this for normal loops. |
| An IIFE | It copies the current value into a function parameter right away. | Older code, from before let was common. |
An extra setTimeout argument | It passes i into the timer function as n. | Timer code that still uses var. |
A forEach parameter | Each call of your function gets its own parameter. | Code that loops over the items of an array. |
This older fix keeps var, but hands each timer the value of i at that moment.
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);
Put each loop under the output it really prints.
Stale closures: reading old data
TICKING DEMO"Stale" means old and out of date. A stale closure is a callback that reads data that was right when the callback was made, but is not the current data any more.
This happens a lot in web pages. Apps often change their data by making a brand-new object, instead of changing the old object. A callback that saved the old object keeps reading it.
You take a photo of the scoreboard at half time. An hour later you look at the photo. It shows the score exactly as it was, but the game has gone on. The photo is correct and out of date at the same time.
- In real life: The score when the photo was taken
- In JavaScript: the object saved when the callback was made
- In real life: The live scoreboard
- In JavaScript: the variable that holds the current data
- In real life: Looking at the old photo later
- In JavaScript: a callback reading stale data
In the example below, the variable settings gets a new object. The callback reads captured instead, and captured still holds the first object.
A stale closure is a callback that reads a variable or object your app no longer updates.
script
function startTicker() { const captured = settings; return () => console.log(captured.label);} const tick = startTicker();settings = { label: "fast" };tick();captured. Line 9 gives settings a new object, but captured still holds the old one. tick() reads captured, so it prints slow.let user = { name: "Asha" };
function registerClick() {
const captured = user;
return () => console.log("Hello", captured.name);
}
const handleClick = registerClick();
user = { name: "Noor" };
handleClick();This code has the same shape as a button's click handler. A click handler is a function the page runs when someone clicks.
- Line 1 makes
user, with the name Asha. - Line 4 saves that object in
captured. - Line 9 gives
usera new object, with the name Noor. - Line 10 "clicks". The handler reads
captured, so it printsHello Asha.
A real click works by the same closure rules. The only difference is that the delay is longer, so the bug is easier to miss.
let settings = { label: "slow" };
function startTicker() {
const captured = settings;
return () => console.log(captured.label);
}
const tick = startTicker();
settings = { label: "fast" };
tick();- Current settingsslowDelay shown700ms
- Modesaved copyStatusstopped
No ticks yet.
Press Start, then New settings. Stop ends the repeating timer, so no old callback keeps running.
UI frameworks such as React make new functions every time the screen redraws. That makes stale closures common there. Each framework has its own fix. The JavaScript choice is always one of these three:
- let the callback read the old data on purpose,
- let it ask for the current data each time it runs, or
- make a new callback whenever the data changes.
Closures that hold memory
REACHABILITYThe Memory: primitives vs objects lesson explains how JavaScript frees memory. Here is the short version:
- A value is reachable if your code can still get to it, through some chain of variables.
- JavaScript frees a value only when nothing can reach it. This is called garbage collection.
- A function that stays around is reachable. So the variables it uses stay reachable too.
A small backpack looks light. But if a heavy book is inside, you carry the book everywhere you go. If you only need one fact from the book, you can write it on a note and leave the book at home.
- In real life: A backpack you keep wearing
- In JavaScript: a returned function that stays around for a long time
- In real life: A heavy book inside
- In JavaScript: a big array or object the function still uses
- In real life: Writing one fact on a note and leaving the book
- In JavaScript: copying
rows.lengthintorowCount
function makeReport() {
const rows = new Array(1_000_000).fill("row");
return () => rows.length;
}
const report = makeReport();
console.log(report());- What the function usesthe returned function reads rows.lengthWhat that meansthe whole rows array can stay in memoryPrinted outputnot run yet
The small returned function reads rows.length, so it can keep the whole big array in memory.
The table lists four common ways memory gets kept. Read each row from left to right: what your code has, why its memory stays, and how to fix it.
| What your code has | Why the memory stays | How to fix it |
|---|---|---|
| A repeating timer | A timer made with setInterval keeps its function alive until you stop it. | Call clearInterval when you no longer need the work. |
| An event listener | The element keeps the listener function, and the function keeps the variables it uses. | Remove the listener, or add it with { once: true } or an abort signal. |
| A big object the function uses | A small returned function can keep a big array, or a big part of the page, in memory. | Copy only the value you need, such as rows.length. |
| A cache with no limit | A cache keeps every saved result on purpose, so it can keep growing. | Limit its size, or use a WeakMap when the keys are objects. |
function makeReport() {
const rows = new Array(1_000_000).fill("row");
return () => rows.length;
}
const report = makeReport();
console.log(report());Line 2 makes an array with a million items. Line 3 returns a function that reads rows.length. That small function uses rows, so the whole array can stay in memory for as long as report exists.
function makeReport() {
const rows = new Array(1_000_000).fill("row");
const rowCount = rows.length;
return () => rowCount;
}
const report = makeReport();
console.log(report());Line 3 copies the length into rowCount, a single number. Line 4 returns a function that reads only rowCount. Both versions print 1000000, but this one no longer needs the array.
report() functions print 1000000. The left one still uses the rows box, so the whole array can stay in memory. The right one only uses rowCount, one small number, so rows can be freed.Sometimes you keep data in a closure on purpose, such as a cache. Then make it a clear choice:
- limit how many results it can hold,
- give it a clear method to empty it, or
- use a
WeakMapwhen the keys are objects, so the cache alone does not keep those keys in memory.
The later Finding memory leaks lesson shows how to prove this in a real app with heap snapshots. A heap snapshot is a list of every object in memory at one moment.
const panel = document.querySelector("#panel");
const handleClick = () => console.log(panel.textContent);
panel.addEventListener("click", handleClick);
panel.remove();
// The listener still reaches the detached panel until cleanup.This code only works in a browser. Line by line:
- Line 1 finds the element with the id
panelon the page. - Line 2 makes a click handler that uses
panel. - Line 3 adds the handler to the element.
- Line 4 removes the element from the page. A removed element like this is called detached.
The handler still uses panel, so the element can stay in memory. To check, open DevTools, take a heap snapshot, search for Detached, and look at what still points at it. To fix it, remove the listener (or abort its signal) when you remove the feature.
- Open the screen and take a heap snapshot.
- Close the screen or go to another page.
- Do the same thing again, then take a second snapshot and compare the two.
- Search for the object that stayed, and follow what points at it: a listener, a timer, a cache, or a closure.
A closure is only a suspect. The chain of things that point at the object is the proof.
Where you will use this
These rules matter whenever code runs later than the place where it was written:
- timers and repeating timers,
- event listeners and click handlers,
- animation callbacks,
- retries after a failed request,
- code that subscribes to updates.
When you debug these, write down two moments: when the callback was made, and when it actually ran.
function startPolling(readUrl) {
const id = setInterval(() => {
console.log("poll", readUrl());
}, 1000);
return () => clearInterval(id);
}"Polling" means asking for new data again and again. Line by line:
- Line 2 starts a repeating timer with
setIntervaland saves its id inid. - Line 3 runs every second. It calls
readUrl(), so it gets the current URL each time, not an old one. - Line 6 returns a stop function. It uses
idon purpose, to stop the timer withclearInterval.
Once the timer is stopped, nothing keeps its function alive, so its memory can be freed.
let selectedUser = "none";
function makePanel(name) {
return {
select() { selectedUser = name; },
read() { return selectedUser; },
};
}
const leftPanel = makePanel("Asha");
const rightPanel = makePanel("Noor");
leftPanel.select();
console.log(rightPanel.read());makePanel makes a new panel object each time you call it. The two panels look separate, but selectedUser is outside makePanel, on line 1. Both panels use that one variable. So when the left panel selects Asha on line 12, the right panel reads Asha too.
The fix is to move selectedUser inside makePanel. Then each call makes its own variable, and each panel keeps its own choice.
function makePanel(name) {
let selectedUser = "none";
return {
select() { selectedUser = name; },
read() { return selectedUser; },
};
}
const leftPanel = makePanel("Asha");
const rightPanel = makePanel("Noor");
leftPanel.select();
console.log(rightPanel.read());Common misconceptions
- "Closures copy values." They keep the variable itself. If the variable changes, the closure reads the new value later.
- "
setTimeoutwith 0 runs right away." It still waits until the code that is running now has finished. - "
conststops stale closures."constonly stops you from putting a new value in that variable. It does not freeze an object, and it does not update old callbacks. - "
letmakes timers run right away."letonly changes whichieach timer reads. The timers still run later. - "JavaScript frees anything I am not using." It frees only values that nothing can reach. A closure, listener, timer, or cache can keep a value reachable.
Practice exercises
5 EXERCISESPredict the three lines the console prints.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}The loop ends with i equal to 3. Every timer function reads that same i, so the output is 3, 3, 3.
This is the loop from exercise 1 with one word changed. What does it print now?
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 timer its own i, so the timers print 0, 1, 2.
This loop uses the timer fix. Predict what it prints.
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 timer function as n. Each timer gets the value i had on its own loop step.
This version of the ticker reads the current settings each time. 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() each time it runs. So it reads the current settings, even after line 6 put a new object there.
This report returns the count without using the whole array. What number does it print?
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 function reads rowCount, not the rows array. It gives the same number without needing the big array.
Quiz
9 QUESTIONSQuestion 1 of 9What 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 9Why does
letfix the timer loop?Choose an answer to see the explanation.
Question 3 of 9What 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 9Which fix lets a callback read the current data without making a new callback?
Choose an answer to see the explanation.
Question 5 of 9What does the report function 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 9Which step stops a closure from staying in memory forever?
Choose an answer to see the explanation.
Question 7 of 9What does this event-handler example print?
Read the code, then predictlet user = { name: "Asha" }; function registerClick() { const captured = user; return () => console.log("Hello", captured.name); } const handleClick = registerClick(); user = { name: "Noor" }; handleClick();Choose an answer to see the explanation.
Question 8 of 9What does the right panel read after the left panel selects Asha?
Read the code, then predictlet selectedUser = "none"; function makePanel(name) { return { select() { selectedUser = name; }, read() { return selectedUser; } }; } const leftPanel = makePanel("Asha"); const rightPanel = makePanel("Noor"); leftPanel.select(); console.log(rightPanel.read());Choose an answer to see the explanation.
Question 9 of 9Which sentence is most accurate?
Choose an answer to see the explanation.
Key takeaways
- With
varin a loop, all callbacks share onei. Withletin the loop header, each loop step gets its owni. setTimeoutfunctions run later, after the current loop or function has finished.- A stale closure reads an old variable or object that your code no longer updates.
- A function that stays around keeps the values it uses in memory. Copy only what you need, and stop timers and listeners when you are done.
A closure bug is a correct memory of the wrong thing: the wrong variable, the wrong moment, or a value that should have been let go.
Up next: The old var & the global object.