completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Predict timer loopsExplain why a loop with var prints 3, 3, 3, and the same loop with let prints 0, 1, 2.
  • 02
    Fix callbacks that read old dataGive a callback a new variable, a function that returns the current data, or a fresh callback.
  • 03
    Stop 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.
One question to ask

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 THROUGH

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

Real-life analogyOne whiteboard or three sticky notes

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 i equal to 3
In real life: A sticky note for each student
In JavaScript: a new let i for each loop step
Top: with var i, three timers all point at one box called i. After the loop it holds 3, so they print 3 3 3. Bottom: with let i, each timer points at its own box called i, holding 0, 1, and 2, so they print 0 1 2.var ione i, shared by alltimer 1timer 2timer 33iafter the loopprints 3 3 3let ia new i for each steptimer 1timer 2timer 30i1i2iprints 0 1 2
With 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.

Replay the timer loop
Step 0 of 10Ready
Your turn: follow the blue line

Predict the console output. The timers run later, after the loop has already ended.

Running in
  1. script
Next: line 1
  setTimeout(() => console.log(i), 0);}
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Change line 1 and predict again

Changing this starts a new replay with the real output.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

setTimeout runs later

REAL OUTPUT

You 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 call fn later.
  • 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.
Important: a delay of 0 still means later

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.

A zero delay still runs laterPop out in the code editor (opens in a new tab)JavaScript
console.log("before");
setTimeout(() => console.log("timer"), 0);
console.log("after");

Line by line:

  1. Line 1 prints before straight away.
  2. Line 2 hands a function to setTimeout. The function waits until the script is done.
  3. Line 3 prints after straight 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".

A console window shows three lines in order: before, from line 1, right away. after, from line 3, right away. timer, from line 2, after the script ends.Consolebeforeline 1, right awayafterline 3, right awaytimerline 2, after the script
Line 2 comes second in the code, but its message comes last. setTimeout saves the function, and the function runs only after lines 1 to 3 have finished.
A tip for finding the bug

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

INTERACTIVE

Every 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:

  • let in 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.

Four ways to print 0, 1, 2
What you writeWhy it worksWhere you will see it
let in the loop headerEach loop step gets a new i.Modern JavaScript. Use this for normal loops.
An IIFEIt copies the current value into a function parameter right away.Older code, from before let was common.
An extra setTimeout argumentIt passes i into the timer function as n.Timer code that still uses var.
A forEach parameterEach call of your function gets its own parameter.Code that loops over the items of an array.
Watch the extra-argument fix
Step 0 of 9Ready
Your turn: follow the blue line

This older fix keeps var, but hands each timer the value of i at that moment.

Running in
  1. script
Next: line 1
  setTimeout((n) => console.log(n), 0, i);}
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Prints 0 1 2 or 3 3 3?
  • 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);
Try it yourself
0 of 6 correct

Put each loop under the output it really prints.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Real-life analogyA photo of the scoreboard

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.

Replay a callback that reads old data
Step 0 of 5Ready
Your turn: follow the blue line

A stale closure is a callback that reads a variable or object your app no longer updates.

Running in
  1. script
Next: line 1
 function startTicker() {  const captured = settings;  return () => console.log(captured.label);} const tick = startTicker();settings = { label: "fast" };tick();
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Top: a box called captured points at the old object, label slow. tick() reads this box and prints slow. Bottom: a box called settings points at a new object, label fast, made on line 9. tick() never reads it.captured{ label: "slow" }old object, line 1tick()prints slowsettings{ label: "fast" }new object, line 9tick() never reads it
Line 4 put the first object in captured. Line 9 gives settings a new object, but captured still holds the old one. tick() reads captured, so it prints slow.
A click handler can keep an old objectPop out in the code editor (opens in a new tab)JavaScript
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.

  1. Line 1 makes user, with the name Asha.
  2. Line 4 saves that object in captured.
  3. Line 9 gives user a new object, with the name Noor.
  4. Line 10 "clicks". The handler reads captured, so it prints Hello 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.

A ticking timer that reads old data
The ticker codePop out in the code editor (opens in a new tab)JavaScript
let settings = { label: "slow" };

function startTicker() {
  const captured = settings;
  return () => console.log(captured.label);
}

const tick = startTicker();
settings = { label: "fast" };
tick();
What is happening
  • Current settingsslow
    Delay shown700ms
  • Modesaved copy
    Statusstopped

No ticks yet.

Try it yourself

Press Start, then New settings. Stop ends the repeating timer, so no old callback keeps running.

Stop, Reset, and leaving the page all stop the timer. If you prefer reduced motion, the ticks are slower.
A note about UI frameworks

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

REACHABILITY

The 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.
Real-life analogyThe backpack a function keeps carrying

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.length into rowCount
What does this function carry?
The function carries the whole arrayPop out in the code editor (opens in a new tab)JavaScript
function makeReport() {
  const rows = new Array(1_000_000).fill("row");
  return () => rows.length;
}

const report = makeReport();
console.log(report());
What stays in memory
  • What the function usesthe returned function reads rows.length
    What that meansthe whole rows array can stay in memory
    Printed outputnot run yet
Try it yourself

The small returned function reads rows.length, so it can keep the whole big array in memory.

This shows the idea, not a real memory measurement. An engine may be smarter, but copying only what you need is the safe habit.

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.

Things that keep memory, and how to fix them
What your code hasWhy the memory staysHow to fix it
A repeating timerA timer made with setInterval keeps its function alive until you stop it.Call clearInterval when you no longer need the work.
An event listenerThe 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 usesA 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 limitA cache keeps every saved result on purpose, so it can keep growing.Limit its size, or use a WeakMap when the keys are objects.
The function carries the whole arrayPop out in the code editor (opens in a new tab)JavaScript
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.

Copy only what the function needsPop out in the code editor (opens in a new tab)JavaScript
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.

Left: the function report reads rows.length, so it points at a box called rows holding a million items. That array can stay in memory. Right: the function report reads rowCount, so it points at a box called rowCount holding 1000000. The rows array can be freed.() => rows.lengthreport()["row", …]rowsa million itemscan stay in memory() => rowCountreport()1000000rowCountjust one numberrows can be freed
Both 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 WeakMap when 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.

A removed element can stay in memoryJavaScript
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:

  1. Line 1 finds the element with the id panel on the page.
  2. Line 2 makes a click handler that uses panel.
  3. Line 3 adds the handler to the element.
  4. 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.

Steps for finding a memory problem
  1. Open the screen and take a heap snapshot.
  2. Close the screen or go to another page.
  3. Do the same thing again, then take a second snapshot and compare the two.
  4. 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.

Stop a repeating timer when you are donePop out in the code editor (opens in a new tab)JavaScript
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:

  1. Line 2 starts a repeating timer with setInterval and saves its id in id.
  2. Line 3 runs every second. It calls readUrl(), so it gets the current URL each time, not an old one.
  3. Line 6 returns a stop function. It uses id on purpose, to stop the timer with clearInterval.

Once the timer is stopped, nothing keeps its function alive, so its memory can be freed.

Two panels share one variable by mistakePop out in the code editor (opens in a new tab)JavaScript
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.

Each panel gets its own variablePop out in the code editor (opens in a new tab)JavaScript
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.
  • "setTimeout with 0 runs right away." It still waits until the code that is running now has finished.
  • "const stops stale closures." const only stops you from putting a new value in that variable. It does not freeze an object, and it does not update old callbacks.
  • "let makes timers run right away." let only changes which i each 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 EXERCISES
Exercise 1 · Warm-upPredict the timer loop

Predict the three lines the console prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFix it with let

    This is the loop from exercise 1 with one word changed. What does it print now?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    for (let i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 0);
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFix it with setTimeout’s extra argument

      This loop uses the timer fix. Predict what it prints.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      for (var i = 0; i < 3; i++) {
        setTimeout((n) => console.log(n), 0, i);
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · ChallengeFix a stale ticker

        This version of the ticker reads the current settings each time. What does it print?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        let settings = { label: "slow" };
        function startTicker(readSettings) {
          return () => console.log(readSettings().label);
        }
        const tick = startTicker(() => settings);
        settings = { label: "fast" };
        tick();

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeStop carrying the whole array

          This report returns the count without using the whole array. What number does it print?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function makeReport() {
            const rows = new Array(1_000_000).fill("row");
            const rowCount = rows.length;
            return () => rowCount;
          }
          const report = makeReport();
          console.log(report());

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz

            9 QUESTIONS
            Closure pitfalls quiz · 9 questionsScore: first tries count
            1. Question 1 of 9What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const callbacks = [];
              for (var i = 0; i < 3; i++) {
                callbacks.push(() => console.log(i));
              }
              callbacks[0]();

              Choose an answer to see the explanation.

            2. Question 2 of 9Why does let fix the timer loop?

              Choose an answer to see the explanation.

            3. Question 3 of 9What does this stale-closure example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              let settings = { label: "slow" };
              const captured = settings;
              settings = { label: "fast" };
              console.log(captured.label);

              Choose an answer to see the explanation.

            4. Question 4 of 9Which fix lets a callback read the current data without making a new callback?

              Choose an answer to see the explanation.

            5. Question 5 of 9What does the report function print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function makeReport() {
                const rows = new Array(3).fill("row");
                const rowCount = rows.length;
                return () => rowCount;
              }
              console.log(makeReport()());

              Choose an answer to see the explanation.

            6. Question 6 of 9Which step stops a closure from staying in memory forever?

              Choose an answer to see the explanation.

            7. Question 7 of 9What does this event-handler example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              let 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.

            8. Question 8 of 9What does the right panel read after the left panel selects Asha?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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());

              Choose an answer to see the explanation.

            9. Question 9 of 9Which sentence is most accurate?

              Choose an answer to see the explanation.

            Key takeaways

            • With var in a loop, all callbacks share one i. With let in the loop header, each loop step gets its own i.
            • setTimeout functions 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.

            CompleteFrontend Clear concepts. Working examples.