Skip to content

Closures and Their Memory Cost

intermediate11 min read

The Most Powerful Pattern in JavaScript

Closures power event handlers, data privacy, React hooks, module patterns, and half of functional programming in JavaScript. Most developers use them daily without realizing it. But few understand what a closure actually captures, how much memory it holds, and when that becomes a problem.

A closure is not magic. It's a function bundled together with a reference to its lexical environment. That reference is what makes closures powerful — and expensive.

Mental Model

Think of a closure as a function with a backpack. When a function is created inside another function, it stuffs a reference to the surrounding scope into its backpack. Wherever that function goes — passed as a callback, returned from a function, stored in a variable — it carries the backpack with it. As long as the function exists, everything in the backpack stays alive in memory.

What Is a Closure, Precisely?

A closure is created when a function is defined inside another function and references variables from the outer function's scope:

function createCounter() {
  let count = 0; // This variable is "closed over"

  return function increment() {
    count++;
    return count;
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
// count is not accessible from outside, but increment
// still has access to it through the closure

After createCounter() finishes, its local variable count would normally be garbage collected. But increment holds a reference to the scope containing count. As long as counter (which references increment) is reachable, count stays alive.

What Closures Actually Capture — The V8 Reality

Here's where most tutorials get it wrong. The specification says a closure captures the entire Lexical Environment. But what does V8 actually do?

function outer() {
  const used = "I'm referenced";
  const unused = new Array(1000000).fill("huge");

  return function inner() {
    console.log(used); // Only references 'used'
  };
}

const fn = outer();
// Question: Is the giant 'unused' array kept in memory?

V8 performs scope analysis at parse time. It identifies which variables a closure actually references and creates a Context object containing only those variables. So in the example above, unused would be garbage collected.

But there's a catch:

function outer() {
  const used = "I'm referenced";
  const unused = new Array(1000000).fill("huge");

  return function inner() {
    console.log(used);
    eval(""); // eval present — V8 can't analyze scope statically
  };
}

const fn = outer();
// Now 'unused' IS kept alive — eval could reference anything
Common Trap

If eval() appears inside a closure (or if new Function() references outer variables), V8 cannot determine at parse time which variables might be accessed. It falls back to capturing the entire scope. This is one reason why eval is discouraged — it defeats scope optimization. The same applies to with statements and arguments in some cases.

The Shared Context Problem

Multiple closures from the same parent share a single Context object:

function setup() {
  const small = "tiny";
  const huge = new Array(1000000).fill("data");

  function useSmall() { return small; }
  function useHuge() { return huge; }

  return useSmall; // We only keep useSmall
  // But if useHuge was also returned/stored somewhere,
  // both closures share the same Context
}

In V8, if any function in the same scope references a variable, that variable goes into the shared Context. So even if useSmall doesn't need huge, if another sibling closure (useHuge) references it and both closures are alive, huge stays in memory.

Execution Trace
Parse
V8 analyzes outer() body
Identifies which variables are referenced by inner functions
Context
Create Context { used, huge }
Both referenced by at least one inner function
Return
useSmall captures Context
Even though useSmall only needs 'small', Context includes 'huge'
GC
huge stays alive
The shared Context keeps all its variables alive

The Classic For-Loop Trap

The most famous closure gotcha:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}
// Logs: 5 5 5 5 5 (not 0 1 2 3 4)

Why? var creates a single i scoped to the containing function. All five closures capture the same i. By the time the timeouts fire, the loop has finished and i is 5.

Three Fixes

// Fix 1: Use let (block scoping creates a new binding per iteration)
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 0, 1, 2, 3, 4
  }, 100);
}

// Fix 2: IIFE (creates a new scope per iteration)
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j); // 0, 1, 2, 3, 4
    }, 100);
  })(i);
}

// Fix 3: bind (partially applies the current value)
for (var i = 0; i < 5; i++) {
  setTimeout(function(j) {
    console.log(j); // 0, 1, 2, 3, 4
  }.bind(null, i), 100);
}
How let creates per-iteration bindings

The ECMAScript spec has special behavior for let in for loops (section 14.7.4.2). At the end of each iteration, the engine creates a new Lexical Environment for the next iteration and copies the current values of loop variables into it. So each iteration's closures capture a different environment with a different binding of i. This is not just syntactic sugar for an IIFE — it's a spec-mandated behavior specific to for loops with let/const.

Production Scenario: The Memory Leak

function setupHandler(element) {
  const hugeData = fetchHugeDataset(); // 50MB of data

  element.addEventListener("click", function handler() {
    // Only uses element.id, but hugeData is in the closure's scope
    console.log("Clicked:", element.id);
  });

  // hugeData is never referenced by handler,
  // but if another closure in this scope references it,
  // or if eval is used, it stays alive.

  // Even after this function returns, if the event listener
  // isn't removed, the closure (and potentially hugeData) persists.
}

The fix — minimize the closure's scope:

function setupHandler(element) {
  const hugeData = fetchHugeDataset();
  processData(hugeData); // Use it, then let it go

  const elementId = element.id; // Extract only what's needed

  element.addEventListener("click", function handler() {
    console.log("Clicked:", elementId); // Tiny closure
  });
}
What developers doWhat they should do
Assuming closures only capture referenced variables
V8 creates one Context per scope level, shared by all closures in that scope
In V8, closures that share a scope share a Context — any variable referenced by ANY sibling closure is kept alive
Using var in for loops with async callbacks
var creates one binding for the entire function. let creates one per iteration.
Use let — each iteration gets its own binding
Creating closures over large objects in event handlers
The closure keeps references alive as long as the handler exists
Extract only the values you need into local variables before creating the closure
Forgetting to remove event listeners when components unmount
Event listeners keep closures alive, which keep their captured scope alive
Store handler references and call removeEventListener in cleanup
Quiz
What does this code log?
Quiz
In V8, if a function contains eval(''), what happens to unused variables in the enclosing scope?
Quiz
After running this code, how many Context objects does V8 create for the closures?
Key Rules
  1. 1A closure is a function plus a reference to its lexical environment — it carries its scope wherever it goes
  2. 2V8 creates a shared Context per scope level — all closures in the same scope share it
  3. 3eval() and with prevent V8's scope optimization, forcing full scope capture
  4. 4Use let in for loops to get per-iteration bindings — var creates one shared binding
  5. 5Minimize closure scope: extract only needed values into local variables before creating closures over large objects