cf.completefrontendCode editorOpen lab
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 var prints 3, 3, 3 while let prints 0, 1, 2.
  • 02
    Fix stale callbacksChoose a fresh binding, a getter, or a recreated callback deliberately.
  • 03
    Avoid 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.

One-question checklist

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 THROUGH

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

Real-life analogyOne whiteboard vs three sticky notes

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 i binding
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 i binding 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.

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

Predict the console output. Timers run later, after the synchronous loop has already finished.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  setTimeout(() => console.log(i), 0);}
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Change line 1 and predict again

Changing the setting starts a fresh replay with 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

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

Important: the delay can be 0 and still be later

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

INTERACTIVE

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

Four ways to print 0, 1, 2
FixIdeaWhen you will see it
let in the loop headerFresh per-iteration bindingModern JavaScript; prefer this for ordinary loops
IIFECopy the current value into a function parameter immediatelyLegacy code before block-scoped let was common
setTimeout extra argumentPass i into the callback as nTimer-specific code that still uses var
forEach parameterEach callback call gets its own parameter bindingArray iteration where indices or values are already items
Watch the timer-argument fix
Step 0 of 9Ready
Your turn: follow the blue line

This old-school fix keeps var, but passes the current value as a callback argument.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  setTimeout((n) => console.log(n), 0, i);}
CallStoreChangeResultRun = next line. 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

Place each loop variant by 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

TICKING DEMO

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

Real-life analogyA photo of the scoreboard

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.

Replay a stale closure
Step 0 of 5Ready
Your turn: follow the blue line

A stale closure is not time travel. It is a callback reading a binding or object that your current app no longer updates.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
 function startTicker() {  const captured = settings;  return () => console.log(captured.label);} const tick = startTicker();settings = { label: "fast" };tick();
CallStoreChangeResultRun = next line. 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.
A real ticking stale-closure demo
Ticker sourcePop 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();
Runtime
  • Current settingsslow
    Preferred delay label700ms
  • Modestale capture
    Statusstopped

No ticks yet.

Try it yourself

Start the ticker, then replace settings. Stop clears the interval so no old callback keeps running.

The interval is cleared on Stop, Reset, and unmount. Reduced-motion users get slower ticks.
UI framework note

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

REACHABILITY

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

Real-life analogyThe backpack a function keeps carrying

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.length into rowCount

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.

What is this closure carrying?
Backpack trap sourcePop 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());
Reachability notes
  • Closure carriesthe returned function reads rows.length
    Memory consequencethe rows array can remain reachable
    Printed outputnot run yet
Try it yourself

The tiny returned function can keep the huge array reachable because it reads rows.length.

This is a reachability model, not a heap snapshot. Engines may optimize, but this is the safe code review habit.
Closure retention patterns and fixes
PatternWhy memory stays reachablePractical fix
Timer callbackA repeating interval keeps its callback reachable.Call clearInterval when the work is no longer needed.
Event listenerThe 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 objectA returned tiny function can keep a big array or DOM subtree reachable.Copy only the needed value, such as rows.length.
Unbounded cacheMemoize closures intentionally retain results.Use a size limit or WeakMap when keys are objects.
Backpack trapPop 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());
Copy only what the closure 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());

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.

A practical timer cleanup patternPop 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);
}

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.” const prevents reassignment of that binding; it does not freeze an object or refresh old callbacks.
  • “let makes callbacks synchronous.” let fixes 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 EXERCISES
Exercise 1 · Warm-upPredict the timer loop

Predict the three console lines.

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 with let

    Change the classic loop so it prints each value. What output should this fixed version produce?

    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 with setTimeout’s extra argument

      Use the timer-specific fix and predict its output.

      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 interval

        Refactor the stale closure so the callback reads the current settings object. 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 · ChallengeRefactor away a heavy capture

          Return the same count without making the closure depend on the whole array.

          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

            7 QUESTIONS
            Closure pitfalls quiz · 7 questionsScore: first tries count
            1. Question 1 of 7What 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 7Why does let fix the classic timer loop?

              Choose an answer to see the explanation.

            3. Question 3 of 7What 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 7Which stale-closure fix keeps a callback fresh without recreating it?

              Choose an answer to see the explanation.

            5. Question 5 of 7What does the report factory 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 7Which cleanup prevents a closure from staying alive forever?

              Choose an answer to see the explanation.

            7. Question 7 of 7Which statement is most accurate?

              Choose an answer to see the explanation.

            Key takeaways

            • var in a loop gives callbacks one shared binding; let in the loop header gives each iteration its own binding.
            • setTimeout callbacks 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.

            CompleteFrontend Clear concepts. Working examples.