Ch. 1 · JavaScript

Closures, Scope & the Classic setTimeout Loop

What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.

~6 min readintermediate

Closures are the topic interviewers reach for when they want to know whether you understand how JavaScript actually resolves names. The question usually arrives disguised as a five-line loop with a timer inside, and the candidate who can explain why it prints three threes, not just how to patch it, stands out immediately. This note builds up from lexical scope to that loop, then covers where closures show up in production code and the memory they can quietly hold on to.

Lexical scope in one minute

Scope is the set of variables a piece of code can see. JavaScript uses lexical (static) scope: the nesting of your source code decides what is visible. Every function call creates a new scope, and blocks create one for let, const and class. To resolve a name, the engine checks the current scope, then the enclosing one, and so on out to the global scope. That path is the scope chain.

const greeting = 'Hello';

function outer() {
  const name = 'Ada';
  function inner() {
    console.log(`${greeting}, ${name}`); // looks up: inner → outer → global
  }
  inner();
}

outer(); // → Hello, Ada
JavaScript

The important word is written. Where a function is called has no effect on which variables it sees:

const x = 'global';

function logX() {
  console.log(x);
}

function run() {
  const x = 'local';
  logX();
}

run(); // → global
JavaScript

logX was written at the top level, so its outer scope is the global one. Calling it from inside run does not give it access to run’s x. (The one part of JavaScript that is decided at call time is this, covered in the this keyword.)

What a closure actually is

A closure is a function bundled with a reference to the scope it was created in. Technically every JavaScript function is a closure, but the idea matters when a function outlives that scope: it is returned, stored, or passed along as a callback.

function makeCounter() {
  let count = 0;
  return function increment() {
    count += 1;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // → 1
console.log(counter()); // → 2

const other = makeCounter();
console.log(other()); // → 1 (a fresh `count`)
JavaScript

When makeCounter returns, its local count would normally be discarded. Because increment still references it, the engine keeps that scope alive. Each call to makeCounter creates a brand-new scope, which is why other starts again at 1.

The detail that sets up the loop question: a closure holds a live reference to the variable, not a copy of its value at creation time.

function makeGetter() {
  let value = 'first';
  const get = () => value;
  value = 'second';
  return get;
}

console.log(makeGetter()()); // → second
JavaScript

Interview tip

Define it precisely: “A closure is a function plus the lexical environment it was created in. It keeps access to those variables, live, even after the outer function has returned.” The word live is what explains the loop.

The classic setTimeout loop

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// → 3 3 3
JavaScript

Two facts combine to produce this:

  1. var is function-scoped (or global at the top level). There is exactly one i for the whole loop, not one per iteration.
  2. Timer callbacks run later. setTimeout schedules a task that can only run once the current script has finished and the call stack is empty (see the event loop). By then the loop has completed and i is 3, the value that failed the i < 3 check.

All three arrow functions close over the same binding and read it when they finally run, so each sees 3. The delay does not change anything: with setTimeout(..., i * 1000) you still get three 3s, just a second apart. The delay is computed during the loop, while i is read afterwards.

Three ways to fix it

1. Use let

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// → 0 1 2
JavaScript

let in a for header gets special treatment: the engine creates a fresh i for every iteration and copies the previous value into it before the increment runs. Each callback closes over its own binding.

2. Create a new scope per iteration

Before ES2015 the only block-free way to get a new scope was a function call. An IIFE (immediately invoked function expression) passes the current value in as a parameter, and each call gets its own parameter binding:

for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 0);
  })(i);
}
// → 0 1 2
JavaScript

A named factory does the same thing and reads better:

function logLater(n) {
  return () => console.log(n);
}

for (var i = 0; i < 3; i++) {
  setTimeout(logLater(i), 0);
}
// → 0 1 2
JavaScript

3. Pass the value as an argument

setTimeout forwards any arguments after the delay to the callback, and they are evaluated at scheduling time. bind achieves the same by pre-filling arguments:

for (var i = 0; i < 3; i++) {
  setTimeout((n) => console.log(n), 0, i);
}
// → 0 1 2

for (var k = 0; k < 3; k++) {
  setTimeout(console.log.bind(console, k), 0);
}
// → 0 1 2
JavaScript

Gotcha

Swapping var for const in for (const i = 0; i < 3; i++) does not work: the first i++ throws TypeError: Assignment to constant variable. A const per iteration is fine in for...of, where nothing is reassigned.

Closures in real code

Private state

Variables in a factory’s scope are unreachable from outside; only the functions created alongside them can read or change them.

function createAccount(initial) {
  let balance = initial; // not reachable from outside

  return {
    deposit(amount) {
      if (amount <= 0) throw new RangeError('Deposit must be positive');
      balance += amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createAccount(100);
console.log(account.deposit(50)); // → 150
console.log(account.balance); // → undefined
console.log(account.getBalance()); // → 150
JavaScript

Classes can now do this with #private fields, but closures remain the idiom for factory functions, modules and React hooks.

Memoization

The cache lives in the closure, so each memoized function gets its own:

function memoize(fn) {
  const cache = new Map(); // lives as long as the returned function does
  return function (arg) {
    if (cache.has(arg)) return cache.get(arg);
    const result = fn.call(this, arg);
    cache.set(arg, result);
    return result;
  };
}

let calls = 0;
const slowSquare = (n) => {
  calls += 1;
  return n * n;
};

const fastSquare = memoize(slowSquare);
console.log(fastSquare(9), fastSquare(9), calls); // → 81 81 1
JavaScript

This version keys on a single argument. Supporting several arguments means building a cache key, which is a good follow-up discussion.

once()

function once(fn) {
  let done = false;
  let result;
  return function (...args) {
    if (!done) {
      done = true;
      result = fn.apply(this, args);
      fn = null; // drop the reference so `fn` can be garbage collected
    }
    return result;
  };
}

const init = once(() => {
  console.log('initializing…');
  return 42;
});

console.log(init()); // → initializing…  then 42
console.log(init()); // → 42 (no second log)
JavaScript

The same pattern (state in the closure, a wrapper function returned) drives debounce and throttle.

Memory considerations

A closure keeps its outer variables alive for as long as the closure itself is reachable. That is the point, but it turns into a leak when a closure lives longer than you intended. The usual suspects are event listeners that are never removed, setInterval timers that are never cleared, and caches like the one in memoize that grow forever.

// Node 22 ships EventTarget and AbortController, same API as the browser
const button = new EventTarget();

function attach(target, signal) {
  const bigData = new Array(1_000_000).fill('*');
  target.addEventListener(
    'click',
    () => console.log(bigData.length), // closure keeps bigData alive
    { signal },
  );
}

const controller = new AbortController();
attach(button, controller.signal);
button.dispatchEvent(new Event('click')); // → 1000000

controller.abort(); // listener removed; bigData can now be collected
button.dispatchEvent(new Event('click')); // → (nothing)
JavaScript

The fixes are unglamorous: remove listeners (an AbortController signal or { once: true } makes this easy), clear timers, cap caches (an LRU, or a WeakMap when keys are objects), and set references to null once they are no longer needed, as once() does.

Note

In V8 (Chrome, Node), all closures created in the same scope share one context object. If any inner function references a large variable, that variable stays alive as long as any of those closures survives, even one that never mentions it. Variables no closure references are collected normally.

The interview answer

“JavaScript is lexically scoped: a function can see the variables of the scopes it was written inside. A closure is a function together with that surrounding environment, and it keeps a live reference to those variables even after the outer function has returned.

In the loop, var creates a single function-scoped i. The callbacks run after the loop has finished, so all three read the same binding, which is now 3. I’d fix it with let, which creates a fresh binding per iteration, or by capturing the current value in a new scope: an IIFE, a factory function, or setTimeout’s extra arguments. In real code I use closures for private state, memoization and helpers like once and debounce, and I watch for closures that outlive their purpose, such as listeners that are never removed.”

More in JavaScript

read ✓JavaScript · hard

The Event Loop: Microtasks vs Macrotasks

How the call stack, task queue and microtask queue fit together, where rendering happens, how async/await schedules work, and output puzzles with answers.

~7 min readread →
read ✓JavaScript · easy

Hoisting, the TDZ and var vs let vs const

What the engine sets up before your code runs, why var reads as undefined while let throws, how the temporal dead zone works, and why const doesn't mean immutable.

~6 min readread →
esc