Frontend

Frontend

Closures & Scope in JavaScript

Why a for-loop with setTimeout logs the wrong number, and what that bug reveals about lexical scope, closures, and memory.

7 min read
JavaScriptClosuresFundamentals

Closures show up in almost every JavaScript interview, and almost every explanation of them starts with the wrong emphasis — "a function that remembers its outer variables" — without ever saying why that's possible. The real answer starts one level down, with how JavaScript decides where a variable is allowed to be read from in the first place.

Lexical scope: it's about where you wrote it, not where you called it

JavaScript uses lexical scoping, which means a variable's accessibility is determined by its position in the source code — where the function is defined — not by where or when that function happens to be called. When the engine parses a function, it fixes its scope chain right then: this function can see its own local variables, plus whatever variables were in scope in the block that physically contains it, all the way out to the global scope. Nothing about calling the function from a different place, or calling it later, changes that chain.

This sounds obvious until you build something on top of it: a function doesn't just get read access to outer variables while its outer function is still running. It keeps that access forever, for as long as the function itself exists.

What a closure actually is

A closure is a function bundled together with a reference to the lexical environment it was created in. It is not a copy of the outer variables' values at creation time — it is a live reference to the variables themselves. So when an inner function is returned from an outer function (or handed off to setTimeout, an event listener, or a promise callback), it carries its enclosing scope along with it. The outer function can finish executing and disappear from the call stack entirely, but the variables it declared stay alive, pinned in memory by the inner function's reference to them.

The precise definition

A closure is formed every time a function is created — not just when it's returned from another function. What makes it interesting is what happens when that inner function outlives the outer call: the outer variables would normally be garbage collected when the outer function returns, but the closure keeps them reachable.

The bug that trips almost everyone up

The most common way this surfaces in an interview is a for loop that schedules a callback with setTimeout. The intuitive expectation is that it prints0, 1, 2 — one per iteration. With var, it doesn't.

javascript
// The classic interview bug
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var:", i), 100);
}
// var: 3
// var: 3
// var: 3

// The fix
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let:", j), 100);
}
// let: 0
// let: 1
// let: 2

The reason is that var is function-scoped, not block-scoped. There is exactly one i for the entire loop, shared by every iteration and by every callback closure created inside it. By the time the first setTimeout callback actually runs — at least 100ms later, well after the loop has finished — the loop has already run to completion and i is sitting at its final value, 3. All three closures captured a reference to the same variable, so all three read the same final value.

let fixes this because it's block-scoped: the specification has each iteration of a let-declared loop create a brand-new binding, initialized by copying the previous iteration's value. That means each closure captures its own private j, frozen at the value it had during that specific iteration — which is exactly the "remembers what it saw when it was created" behavior people expect from closures in the first place.

Closures aren't just an interview trap — they're a tool

Outside of trick questions, closures are how a lot of everyday JavaScript patterns actually work:

  • Memoization — a cache object declared in an outer function, read and written by an inner function that's returned to the caller. The cache has no existence outside that closure, so callers can't accidentally corrupt it.
  • Private state without classes — a variable declared inside a factory function is completely inaccessible from the outside; only the functions returned alongside it can read or mutate it. This is how encapsulation worked in JavaScript long before class and #private fields existed, and it's still common in module-style code today.
  • Debounce and throttle — both need to remember something between calls: a pending timer ID for debounce, or the timestamp of the last invocation for throttle. That piece of state has to live somewhere that survives between separate calls to the returned function but isn't visible to anyone else — which is precisely what a closure over a variable in the wrapping function provides.

The memory cost of remembering

Closures keep their captured variables alive for as long as the closure itself is reachable — that's the whole mechanism, and it's also the risk. If a long-lived closure (an event listener attached to window, a subscription callback that's never unsubscribed) captures a reference to something large — a DOM node, a big array, an entire component's state — that object can't be garbage collected until the closure itself goes away. This is a real, if occasional, source of memory leaks in long-running apps, and it's usually invisible until a profiler shows retained memory that should have been freed long ago.

Watch what you capture

Cleaning up event listeners and subscriptions isn't just good hygiene — it's the only way to release whatever those closures were holding onto. If a listener captures a large object only to use one small piece of it, consider extracting just that piece before the closure is created.

Key takeaways

  • Lexical scope means a function's variable access is fixed by where it's written, not by where or when it's called.
  • A closure is a function plus a live reference to its enclosing scope — it keeps outer variables alive after the outer function returns.
  • The var-in-a-loop bug happens because var creates one shared binding for the whole loop; let creates a fresh binding per iteration.
  • Memoization, private state, and debounce/throttle all rely on a closure remembering state between separate calls.
  • A closure keeps everything it captures alive for as long as it's reachable — an easy, occasional source of memory leaks via forgotten listeners.
2026 — Built by Arpit Mishra