Topics
Languages & Runtimes

Closures and Scope

How JavaScript resolves a variable, why a function keeps its birthplace alive, the loop puzzle, stale closures in React, and what closures cost in memory.

Intermediate·13 min read·Updated Sep 30, 2026

A closure is a function together with the variables that were in scope where it was written. JavaScript functions keep a live reference to that environment, not a copy, so they can read and change outer variables long after the outer function has returned. Scope is decided by where code sits in the source (lexical), never by where it is called from. Once you see that every call creates an environment record and every function points at the record it was born in, the loop puzzles, the module pattern, stale React state and closure memory leaks all become the same mechanism.

Context

Closures come from Lisp: Scheme (1975) made functions first-class values that carry their defining environment with them. JavaScript borrowed that model in 1995 along with Java's syntax, which is why a language that looks like C lets you return a function from a function and have it still see the caller's locals. For its first twenty years the only variable declaration was var, scoped to the whole function and hoisted to its top, and that produced the most famous JavaScript puzzle, the loop that logs the same number three times. Before ES modules existed, closures were also how code got privacy: the module pattern and the immediately invoked function expression (IIFE) wrapped everything in a function so its locals could not leak.

ES2015 changed the terrain: let and const are block-scoped with a fresh binding per loop iteration, and modules replaced the IIFE. Closures did not go away; React hooks (2019) put them at the centre of UI code, where a callback that captured last render's state is called a stale closure. You have met closures as every event handler that used a variable from outside, every debounce helper, and every useEffect that logged an old value.

The simplest closure is a counter that nobody else can reset:

counter.js
function makeCounter() {
  let count = 0                       // lives in makeCounter's environment
  return () => ++count                // this arrow closes over count
}
const next = makeCounter()            // makeCounter has returned…
next(); next()
console.log(next())                   // 3  …but count is still alive
console.log(makeCounter()())          // 1  a second call, a second environment
Scope
The region of source code in which a name refers to a given variable. Function, block, module or global.
Lexical (static) scope
Scope determined by the nesting of the source text at write time, not by the call stack at run time.
Environment record
The spec’s name for the object that holds a scope’s variables at run time; one is created per function call and per block.
Scope chain
The linked list from the current environment outward to the global one; name lookup walks it until it finds a match.
Hoisting
Declarations are known for the whole scope before any code runs. var is initialised to undefined; let/const exist but are unreadable until their line.
Temporal dead zone (TDZ)
The window between entering a block and executing a let/const declaration, during which reading it throws a ReferenceError.

Why it matters

Closures are not an advanced feature you opt into; every callback, promise handler, middleware and hook is one. The bugs they cause are specific and common: a handler that sees the value from three renders ago, a loop that registers ten listeners all reading the last index, a timer that keeps a 50 MB result alive because a sibling function still references the scope. Being able to say "this function closed over that record, and the record still holds this value" is how those bugs take five minutes instead of an afternoon.

How a name gets resolved

When a function is called, the engine creates a new environment record for its parameters and locals and links it to the record the function was created in; the function object carries that link (the spec calls it [[Environment]]). Reading a name walks the chain outward until it finds a binding. Calling the same factory twice gives two independent chains, which is why two counters do not share a count.

global recordmakeCounter · a · bmakeCounter() call #1count = 3makeCounter() call #2count = 1a = () => ++count[[Environment]] → call #1b = () => ++count[[Environment]] → call #2
Two counters from the same factory. Each call to makeCounter created its own environment record; each returned arrow points at the record it was created in.
  1. 1
    const a = makeCounter(): a call record is created with count = 0, linked to the global record. The arrow function object is created inside it and stores a reference to that record. The record would normally be garbage after return, but the arrow holds it.
  2. 2
    a(): a new record for the arrow's own call (empty, it has no locals) is linked to call record #1. count is not found locally, so lookup steps outward, finds it in record #1, and increments it there.
  3. 3
    const b = makeCounter(): a completely separate call record #2 with its own count. b points at #2; a still points at #1. Neither can see the other's variable.
  4. 4
    Because the arrow references the record, not a snapshot, a change made by anyone with access to that record is visible on the next call. A closure captures variables, not values.

The loop puzzle and block scope

var creates one binding per function, so every callback created in the loop closes over the same i, and by the time the timers fire the loop has finished and i is 3. let in a for head is special-cased by the spec: each iteration gets a fresh binding, copied from the previous one, so each callback closes over its own i.

var.js
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0)
}
// 3 3 3  — one i, shared; loop ended before any timer ran

// pre-2015 fixes: a new scope per iteration
for (var j = 0; j < 3; j++) {
  ;(function (k) {
    setTimeout(() => console.log(k), 0)  // 0 1 2
  })(j)
}
// or: setTimeout(console.log, 0, j) — pass the value as an argument
let.js
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0)
}
// 0 1 2  — a fresh i per iteration, each closure has its own

// forEach gives a fresh parameter per call for the same reason
;[0, 1, 2].forEach(i => setTimeout(() => console.log(i), 0))  // 0 1 2
var
function record: i = 3all 3 callbacks → same binding
let
iter 0: i = 0iter 1: i = 1iter 2: i = 2each callback → its own binding
What the callbacks can see. With var there is one record for the whole function; with let each iteration has its own record, chained to the previous one.
varletconst
ScopeFunction (or global)BlockBlock
Hoisted asundefined, readable earlyExists but in the TDZ until its lineSame as let
Redeclare in same scopeAllowed (silently)SyntaxErrorSyntaxError
Per-iteration binding in forNoYesn/a (cannot be reassigned by the update)
Global declarationBecomes a property of globalThisGlobal lexical binding, not a propertySame as let

Closures at work

Most utility functions people are asked to write in interviews are closures over a small piece of private state: a timer handle, a cache, a "has this run" flag.

debounce.ts
export function debounce<A extends unknown[]>(
  fn: (...args: A) => void,
  ms: number,
) {
  let timer: ReturnType<typeof setTimeout> | undefined   // private state
  return (...args: A) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), ms)
  }
}
// each debounce() call has its own timer; nothing outside can touch it
once-and-memo.ts
export function once<T>(fn: () => T) {
  let done = false, value: T
  return () => (done ? value : ((done = true), (value = fn())))
}

export function memo<K, V>(fn: (k: K) => V) {
  const cache = new Map<K, V>()
  return (k: K) => {
    if (!cache.has(k)) cache.set(k, fn(k))
    return cache.get(k)!
  }
}
// the Map lives exactly as long as the returned function does

The stale closure in React

Every render of a function component is a new call, so every handler and effect created during it closes over that render's variables. A callback stored somewhere long-lived (a timer, a subscription, a ref) keeps seeing the render it came from, even after state has moved on. The fix is either to recreate the callback when its inputs change (dependency arrays), or to stop depending on the captured value at all.

Stale.tsx
function Timer() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1)   // count is the value from the FIRST render: 0
    }, 1000)                // so this sets 1, 1, 1, 1…
    return () => clearInterval(id)
  }, [])                    // [] → effect never re-created
  return <p>{count}</p>
}
Fresh.tsx
function Timer() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1)  // updater form: no captured count at all
    }, 1000)
    return () => clearInterval(id)
  }, [])
  return <p>{count}</p>
}
// alternatives: put count in the dependency array (interval is re-created
// each tick), or read the latest value through a ref you update on render

What closures cost

A closure keeps its whole environment record reachable, and the engine does not know which variables the function will use later, so it keeps the ones it might. V8 optimises by allocating a shared context object per scope that contains only the variables some inner function references, but that has a consequence: if any inner function references a large variable, every other closure from the same scope keeps it alive too, even one that never mentions it.

leak.js
function setup() {
  const big = loadFiftyMegabytes()
  const log = () => console.log(big.length)   // references big

  window.addEventListener('resize', () => {
    console.log('resized')                     // never uses big…
  })
  // …but shares setup()'s context with log, and the listener lives
  // as long as window does → big is never collected
}
fixed.js
function setup() {
  const size = loadFiftyMegabytes().length      // keep only what you need
  const log = () => console.log(size)

  const onResize = () => console.log('resized')
  window.addEventListener('resize', onResize)
  return () => window.removeEventListener('resize', onResize)  // and detach
}
// rule: long-lived closures (listeners, timers, caches, globals) should
// close over small values, and have an explicit teardown

Pitfalls

  • Expecting a closure to copy the value

    A closure holds a reference to the binding. If the variable is reassigned after the function was created, the function sees the new value, which is the whole var-loop problem. To freeze a value at creation time, pass it as an argument or declare a new const in an inner scope.

  • Silencing the exhaustive-deps lint rule

    The rule exists because an effect or callback that uses a value not in its dependency array will keep the render it was created in. Disabling it hides a stale closure rather than fixing one. Use functional updates, move the value into the effect, or use a ref if you genuinely want "latest, without re-running".

  • Shadowing the variable you meant to close over

    An inner let data or a parameter with the same name as an outer variable creates a new binding, and the closure resolves to the inner one. It is legal, produces no warning, and reads correctly at a glance. Linters can flag it (no-shadow).

  • Async code mutating shared closed-over state

    Two overlapping requests that both close over the same results array, or a current flag, interleave at every await. The environment is shared, so the second call sees the first call's partial writes. Keep per-request state in the call's own scope and return it, rather than writing to an outer variable.

  • Reaching for a closure when you need this

    Closures capture variables; they do not capture the object a method was called on. A callback inside a method that reads this.value works only if it is an arrow (which closes over this lexically) or was bound. That is the next topic.

Interview questions

Q1What is a closure, and where do you use one deliberately?

A function bundled with a live reference to the environment it was created in, so it can read and update those variables after the enclosing function has returned. Deliberately: private state without a class (counters, caches, a debounce timer), factories that configure a function once and return it, and callbacks that need context from where they were defined. Every event handler and effect in React is one whether you intend it or not.

Q2What does this loop print, and what are three ways to fix it?

With var and setTimeout it prints the final value three times, because all three callbacks close over the single function-scoped binding, and the timers run after the loop finished. Fixes: use let, which gives each iteration its own binding; wrap the body in an IIFE or a helper that takes the index as a parameter; or pass the value to setTimeout as an extra argument so no closure over the loop variable is needed.

Q3Implement debounce. Where does the state live?

A function that takes a callback and a delay, keeps a timer handle in its own scope, and returns a function that clears the timer and starts a new one that calls the callback with the latest arguments. The timer lives in the outer function's environment record, captured by the returned function, so each call to debounce creates an independent debounced function with its own timer, and nothing else can reach it.

Q4What is the temporal dead zone?

The period between entering a scope and executing a let, const or class declaration inside it. The binding is hoisted, so the name is reserved for the whole block, but reading it before the declaration line throws a ReferenceError instead of returning undefined as var would. It exists to make use-before-declare an error, and it is why a closure created above the declaration can still work as long as it is called after it.

Q5Explain a stale closure in a React effect and how you would fix it.

Each render is a new function call with its own state variables, and an effect with an empty dependency array runs once, so any callback it registers keeps the first render's values forever: an interval that does setCount(count + 1) sets 1 every tick. Fixes: use the functional updater so nothing is captured, add the value to the dependency array so the effect is recreated when it changes, or store the latest value in a ref that the callback reads.

Q6Can closures cause memory leaks?

Yes, when a long-lived closure keeps a large environment reachable. A listener or timer callback retains its scope for as long as it is registered, and in V8 sibling closures share one context object, so a callback that never touches a large variable still keeps it alive if another function in the same scope references it. Prevent it by closing over small derived values, and by removing listeners and clearing timers in a teardown.

Q7What is the difference between scope and context?

Scope is about variables and is lexical: it is fixed by where the function is written and resolved through the environment chain. Context usually means this, which is dynamic: it is decided by how the function is called, except in arrow functions, which have no own this and take it from the enclosing scope, which is the one place the two concepts meet.

Key takeaways
  • A closure is a function plus a reference to the environment record it was created in. It captures variables, not values, and keeps that record alive.
  • Scope is lexical: resolved by walking outward from where the code was written. Each call creates a new record, so two calls of a factory never share state.
  • var is one binding per function; let/const are per block and get a fresh binding per loop iteration, which is the whole fix for the setTimeout puzzle.
  • debounce, once, memoize and the module pattern are closures over small private state; React handlers and effects are closures over one render.
  • Stale closures come from long-lived callbacks capturing an old render. Use updater functions, correct dependencies or a ref.
  • Long-lived closures retain their whole context, and V8 shares it between siblings. Close over small values and tear listeners and timers down.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free