The Secret Life of JavaScript: Understanding Closures

Published: (December 11, 2025 at 10:15 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

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

“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.”

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"

“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.”

Returning an Inner Function

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?!

“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 replied. “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 Analogy

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"

“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, and the returned function gets that specific greeting in its backpack. They are completely independent.”

Closures for Data Privacy

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

“All three methods are closures over balance. They all see the same balance value, right?”

“Yes. And crucially, it’s a live reference, not a copy.”

Further demonstration:

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.

Attempting to access the private variable directly fails:

console.log(account.balance); // undefined
account.balance = 9999;       // Creates a new property; does not affect the closure variable
console.log(account.getBalance()); // Still 230

“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 Module Pattern

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.

“This is a module. You use an immediately‑invoked function expression (IIFE) to create a private scope, then return an object with public methods that access those private variables. Before ES6 classes existed, the Module Pattern was the primary way to create objects with private state in JavaScript.”

A Classic Closure Pitfall

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

“This is the var problem from Chapter 1, mixed with closures. With var, the variable i belongs to the entire setupButtons function, not to the loop block. All three callbacks share the same i, which after the loop ends has the value 4 (the loop exits when i becomes 4).”

Fixes: use let for block scoping or create a new scope for each iteration.

// Using let (ES6)
function setupButtons() {
  for (let i = 1; i <= 3; i++) {
    setTimeout(function() {
      console.log("Button " + i + " clicked");
    }, 1000);
  }
}
// Using an IIFE
function setupButtons() {
  for (var i = 1; i <= 3; i++) {
    (function(index) {
      setTimeout(function() {
        console.log("Button " + index + " clicked");
      }, 1000);
    })(i);
  }
}
Back to Blog

Related posts

Read more »

Swift #12: Funciones

Las funciones son bloques de código delimitados por llaves { e identificados por un nombre. A diferencia de los bloques de código usados en los bucles y condici...