DEV Community

Cover image for The Secret Life of JavaScript: Understanding Closures
Aaron Rose
Aaron Rose

Posted on

The Secret Life of JavaScript: Understanding Closures

Chapter 4: Functions Remember

Timothy arrived at the library soaking wet, rain dripping from his jacket. He'd been running through London's streets, desperate to talk to Margaret about something that had broken his understanding of how functions work.

"It's impossible," he said, shaking water from his hair. "I wrote code that violates everything I learned about memory and scope."

Margaret looked up from her tea. "Show me."

Timothy pulled out his laptop and typed:

function createCounter() {
  let count = 0;

  return function() {
    count++;
    console.log(count);
  };
}

const counter = createCounter();

counter(); // 1
counter(); // 2
counter(); // 3
Enter fullscreen mode Exit fullscreen mode

"I don't understand," Timothy said. "When createCounter finishes running, its local variables should be garbage collected. The count variable should be gone. But the returned function still knows about it. It's accessing a variable that shouldn't exist anymore."

Margaret smiled. "You've discovered closures. And you're right to be confused—it looks like a violation of how programming languages work. Except in JavaScript, it's not a bug. It's a feature."


The Mechanism: Lexical Scope

Margaret pulled out her notebook. "Before we explain what's happening, we need to understand lexical scope. You know scope from Chapter 1—where variables live. But there's something deeper."

She wrote:

function outer() {
  let outerVar = "I'm in outer";

  function inner() {
    console.log(outerVar); // Can I see this?
  }

  inner();
}

outer(); // "I'm in outer"
Enter fullscreen mode Exit fullscreen mode

"When inner looks for outerVar, where does it look?" Margaret asked.

"It looks where the variable is declared," Timothy answered. "In the outer function."

"Exactly. It doesn't matter where inner is called. It matters where inner was written. This is lexical scope: functions look for variables in the scope where they were defined, not where they are executed."

Timothy nodded. This matched what he'd learned in Chapter 1.

"Now," Margaret continued, "what happens if we return inner from outer?"

function outer() {
  let outerVar = "I'm in outer";

  function inner() {
    console.log(outerVar);
  }

  return inner; // Return the function itself
}

const myFunction = outer(); // outer() finishes here
myFunction(); // "I'm in outer" - but how?!
Enter fullscreen mode Exit fullscreen mode

"When outer finishes executing, its local variables should disappear," Timothy said slowly. "But inner is still accessing outerVar. How is that variable still alive?"

"Because JavaScript doesn't let it die," Margaret said. "When you return inner, JavaScript says: 'This function references outerVar. I can't garbage collect that variable yet, because the function might be called later and it will need that variable.' So JavaScript keeps outerVar in memory specifically for inner to use."

"So the variable persists?"

"The variable persists. The function remembers its birthplace. This combination—a function plus the variables it needs from its scope—is called a closure."


The Backpack Metaphor

Margaret leaned back. "Imagine every function gets a backpack when it's created. As the function is being defined, JavaScript looks around and says: 'What variables will this function need?' Then it packs them into the backpack."

function createGreeter(greeting) {
  // createGreeter's scope

  return function(name) {
    // This inner function needs 'greeting' from createGreeter's scope
    console.log(greeting + ", " + name);
  };
}

const sayHello = createGreeter("Hello");
const sayHi = createGreeter("Hi");

sayHello("Alice"); // "Hello, Alice"
sayHi("Bob");      // "Hi, Bob"
Enter fullscreen mode Exit fullscreen mode

"When you define the inner function, it looks around. It sees greeting. It packs greeting into its backpack. Later, no matter where that function is called, it carries greeting with it."

Timothy pointed at the code. "So sayHello and sayHi have different backpacks? Different greeting values?"

"Exactly. Each time createGreeter runs, a new scope is created. A new greeting variable is created. The returned function gets that specific greeting in its backpack. So sayHello remembers 'Hello', and sayHi remembers 'Hi'. They're completely independent."

"That's the closure," Timothy said, understanding dawning. "The function plus its backpack."

"Precisely."


Live References, Not Snapshots

Timothy typed quickly:

function createAccount() {
  let balance = 100;

  return {
    deposit: function(amount) {
      balance += amount;
      console.log("Balance: " + balance);
    },

    withdraw: function(amount) {
      balance -= amount;
      console.log("Balance: " + balance);
    },

    getBalance: function() {
      return balance;
    }
  };
}

const account = createAccount();
account.deposit(50);   // Balance: 150
account.withdraw(20);  // Balance: 130
account.getBalance();  // 130
Enter fullscreen mode Exit fullscreen mode

"Here," Timothy said, "I'm returning an object with three methods. All three are closures over balance. But they all see the same balance value, right?"

"Yes. And crucially, it's a live reference. Not a copy."

Margaret took the keyboard and added:

const account = createAccount();

account.deposit(50);   // Balance: 150
account.withdraw(20);  // Balance: 130
account.deposit(100);  // Balance: 230

// All three methods access the SAME variable
// If one changes it, all others see the change
Enter fullscreen mode Exit fullscreen mode

"Think about what this means," Margaret said. "All three functions are in the same backpack. They're all holding a reference to the exact same memory location where balance lives. It's not three separate copies of 100. It's one shared variable that all three functions can read and modify."

Timothy realized something. "And no one from outside can touch balance directly, can they?"

He tried:

console.log(account.balance); // undefined
account.balance = 9999;       // This creates a new property, doesn't change the closure variable
console.log(account.getBalance()); // Still 230
Enter fullscreen mode Exit fullscreen mode

"Exactly," Margaret said. "The balance variable is completely hidden. The only way to interact with it is through the three methods you exposed. This is genuine data privacy."


The Power of Privacy

Timothy leaned back. "In Python, we'd use a leading underscore like _balance and hope people don't touch it. But here, it's actually impossible for someone to touch it."

"Right. And that's incredibly powerful. You can build what's called the Module Pattern."

Margaret wrote:

const calculator = (function() {
  let lastResult = 0;

  return {
    add: function(a, b) {
      lastResult = a + b;
      return lastResult;
    },

    subtract: function(a, b) {
      lastResult = a - b;
      return lastResult;
    },

    getLastResult: function() {
      return lastResult;
    }
  };
})();

calculator.add(10, 5);        // 15
calculator.subtract(20, 8);   // 12
calculator.getLastResult();   // 12

// lastResult is private—no one can access or modify it except through these methods
Enter fullscreen mode Exit fullscreen mode

"This is a module," Margaret explained. "You use an immediately-invoked function expression—the function runs right away—to create a scope. Inside, you define private variables like lastResult. Then you return an object with public methods that access those private variables."

"It's like building a class, but without using the class keyword," Timothy said.

"Exactly. Before ES6 classes existed, the Module Pattern was how you created objects with private state in JavaScript. And even today, it's still used for organizing code."


The Classic Loop Trap

Margaret's expression grew serious. "Now I must warn you about the most infamous closure trap in JavaScript history."

She wrote:

function setupButtons() {
  for (var i = 1; i <= 3; i++) {
    setTimeout(function() {
      console.log("Button " + i + " clicked");
    }, 1000);
  }
}

setupButtons();
Enter fullscreen mode Exit fullscreen mode

"Run this. What do you expect?"

Timothy thought. "It should print 'Button 1 clicked', then 'Button 2 clicked', then 'Button 3 clicked'."

Margaret ran it.

Button 4 clicked
Button 4 clicked
Button 4 clicked
Enter fullscreen mode Exit fullscreen mode

Timothy stared. "What? Why 4? And why all the same number?"

"This is the var problem from Chapter 1, mixed with closures," Margaret said. "Watch carefully. With var, the variable i belongs to the function setupButtons, not to the loop block. There is only one i variable."

She traced through the execution:

// The loop runs:
// i = 1, create callback, schedule it
// i = 2, create callback, schedule it
// i = 3, create callback, schedule it
// i = 4, exit loop

// One second later, all three callbacks fire.
// Each callback's closure contains 'i'.
// What is i right now? 4.
// All three print 4.
Enter fullscreen mode Exit fullscreen mode

"The three callbacks all close over the same i variable," Timothy whispered. "They don't capture its value at creation time. They reference the live variable. By the time they run, it's 4."

"Exactly. This trap has caught millions of JavaScript developers. The solution in the old days was ugly."

She showed him:

// The old workaround
for (var i = 1; i <= 3; i++) {
  (function(j) {
    setTimeout(function() {
      console.log("Button " + j + " clicked");
    }, 1000);
  })(i); // Pass i as j
}
Enter fullscreen mode Exit fullscreen mode

"This creates a new function for each iteration, and j becomes a local variable of that function, capturing the current value of i. It works, but it's ugly."

Timothy groaned. "There has to be a better way."

"There is. Remember Chapter 1? Block scope."

Margaret changed one word:

for (let i = 1; i <= 3; i++) {
  setTimeout(function() {
    console.log("Button " + i + " clicked");
  }, 1000);
}
Enter fullscreen mode Exit fullscreen mode

"With let, JavaScript creates a new i for each iteration. Every time the loop body runs, there's a fresh i variable. Each callback closes over its own i."

Timothy tested it. It worked perfectly:

Button 1 clicked
Button 2 clicked
Button 3 clicked
Enter fullscreen mode Exit fullscreen mode

"So let solves this because of block scope?"

"Yes. var is function-scoped—one variable for the entire function. let is block-scoped—a new variable for each loop iteration. Each callback closes over a different variable. Problem solved."

Timothy smiled. "I'm starting to understand why you said JavaScript is all about scope and context."


Closures in the Real World

Margaret pulled up a practical example:

function makeButton(label, callback) {
  const button = document.createElement('button');
  button.textContent = label;

  button.addEventListener('click', function() {
    callback();
  });

  return button;
}

let clickCount = 0;

const myButton = makeButton('Click Me', function() {
  clickCount++;
  console.log("Clicked " + clickCount + " times");
});

document.body.appendChild(myButton);
Enter fullscreen mode Exit fullscreen mode

"This is how JavaScript handles events in the real world," Margaret said. "The event handler closes over clickCount. Every time you click the button, the handler runs and can access and modify clickCount."

Timothy nodded. "That's why event handlers are so powerful. They remember their context."

"Exactly. And here's another pattern you'll see everywhere—function factories with configuration:"

function createMultiplier(factor) {
  return function(number) {
    return number * factor;
  };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(5));  // 10
console.log(triple(5));  // 15
Enter fullscreen mode Exit fullscreen mode

"Each returned function closes over its own factor. You create specialized versions of the same function. This is incredibly common in JavaScript libraries."


The Rule of Thumb

Margaret stood and walked to the window, looking out at the rain-soaked London streets.

"Timothy, here's what you need to understand about closures:

  1. Every function has closure potential. When a function is created inside another function and accesses variables from that outer scope, it becomes a closure. More precisely, a closure forms when a function outlives the scope it was defined in but still needs to access variables from that scope.

  2. Lexical scope determines what variables are accessible. A function can see variables from its own scope and all parent scopes.

  3. The backpack contains live references. When a function closes over a variable, it holds a reference to that variable. If the variable changes, the closure sees the change.

  4. Variables persist as long as closures reference them. Garbage collection cannot delete a variable if a closure still needs it. This is powerful, but be mindful—if a closure holds a reference to a large object, that object cannot be garbage collected, which can cause memory leaks if you're not careful.

  5. This enables data privacy. You can create private variables that only specific functions can access.

  6. This enables factories. You can create multiple functions with independent state.

  7. Block scope with let prevents common bugs. In loops, let creates a fresh variable for each iteration, giving each closure its own reference."

She turned back to Timothy. "Closures aren't a quirk. They're the foundation of functional programming. Every callback you write is a closure. Every module you build uses closures. Every event handler uses closures."

Timothy closed his eyes for a moment. "So when I write an event handler, the function I pass to addEventListener closes over all the variables it needs from the surrounding scope?"

"Yes. And it carries those references with it, using them when the event fires, even if the original scope has long since finished executing."

"That's... actually beautiful," Timothy said.

Margaret smiled. "It is. Once you stop seeing closures as a bug and start seeing them as a feature, JavaScript clicks into place."


Key Takeaways

  1. A closure is a function plus its lexical scope — When a function is created, it gains access to all variables in the scope where it was defined, even after that scope has finished executing.

  2. Lexical scope means 'where it's written' — Functions look for variables where they are defined in the code, not where they are called.

  3. Each closure gets a backpack — When a function is created, it captures the variables it needs from its parent scope.

  4. Closures hold live references, not copies — If a variable in a closure changes, the function sees the new value. All closures over the same variable share that reference.

  5. Variables persist if closures reference them — JavaScript keeps variables in memory as long as a closure could access them, even if the outer function has finished.

  6. Closures enable data privacy — Variables in a closure are only accessible through the functions that close over them. No external code can touch them directly.

  7. The Module Pattern uses closures — Return an object with methods that access private variables, creating a module with public interface and private state.

  8. var in loops creates a shared variable — All callbacks in a loop close over the same var variable, which has the final loop value by the time callbacks run.

  9. let in loops creates per-iteration variables — Each iteration of the loop has its own let variable, so each closure has its own reference.

  10. Closures are everywhere in JavaScript — Event handlers, callbacks, factories, and modules all rely on closures to work.


Discussion Questions

  1. Why do you think JavaScript decided to keep variables alive if closures reference them, rather than making them disappear?

  2. In the Module Pattern, why is the outer function immediately invoked (function() { ... })()? Why not just define a regular function?

  3. When you pass a callback to addEventListener, what variables does it close over, and why is that useful?

  4. If a closure holds a reference to a large object, what happens to that object's memory? Can it be garbage collected?

  5. How would you fix the classic loop problem without using let—what would the workaround be?

Share your closure discoveries and practical examples in the comments!


About This Series

The Secret Life of JavaScript reveals how JavaScript actually works—not how you wish it worked. Through the conversations of Timothy (a curious developer learning his first new language) and Margaret (a wise JavaScript expert) in a Victorian library in London, we explore the ideas beneath JavaScript's quirky syntax.

Each chapter builds on the last:

  • Chapter 1: Variables — where they live and how scoping works
  • Chapter 2: this — context and binding
  • Chapter 3: Prototypes — objects delegate, they don't copy
  • Chapter 4: Closures — functions remember their birthplace

Coming next: "The Secret Life of the Event Loop" — how a single-threaded language manages callbacks, timers, and asynchronous code without freezing.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (1)

Collapse
 
kamil7x profile image
Kamil Trusiak

Great writing, as always!

Just a quick question, could you use Series feature to link all chapters? It would be nice to have it all connected, especially in future, when someone discover chapter from the middle of story at first