JavaScriptPublished 9/26/2023

Understanding JavaScript Closures: Why Can a Function Remember Outer Variables?

Break down how JavaScript closures work through the classic loop trap, understand how functions "remember" variables in their lexical scope, and learn to use them for real-world patterns like counters and data privacy.

What Is a Closure?

Let's start with a plain-language definition: a closure is a function that packs up the scope it was born in and carries it along wherever it goes. Even after the outer function has long finished running, the inner function can still reach into those variables.

Think of it like a backpack: when a function is born inside some scope, it stuffs every variable it can currently see into its own backpack. From then on, no matter where that function gets passed off to run — even after the original scope has technically "disappeared" — it can still unzip the backpack and pull out the variables it packed back then.

Here's a simple example:

js
function makeCounter() {
  let count = 0
  return () => ++count
}
 
const counter = makeCounter()
counter() // 1
counter() // 2
counter() // 3

Once makeCounter finishes running, you'd expect its count to get garbage collected. But because the returned arrow function is "carrying" count in its backpack, every call to counter() can still read and update that same count. That's the closure at work: the inner function's reference to an outer variable keeps that variable alive well past the outer function's own lifetime.

Spotting the Classic Loop Bug

Now that you've got the concept, take a look at this code and guess what's wrong with it first:

js
for (var i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, 100)
}
// output: 4 → 4 → 4

The key issue is that var is scoped to the whole function (or the global scope) — not to each individual loop iteration. That means all three setTimeout callbacks are actually carrying the same backpack, holding the same i. By the time each callback finally runs 100ms later, the loop has long since finished and i has already become 4. So when all three callbacks unzip their backpack, they all find the same i — and it's 4.

Fix this by giving each loop iteration its own independent backpack. The easiest way is to swap var for let:

js
for (let i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, 100)
}
// output: 1 → 2 → 3

let has a crucial property: every loop iteration gets a brand-new block scope. So each of the three callbacks carries its own independent i, and printing 1, 2, 3 just falls out naturally.

If you're stuck maintaining an older codebase where var is your only option, you can still use a closure to manually create an independent scope — this was the classic workaround before let existed:

js
for (var i = 1; i <= 3; i++) {
  ;(function (capturedI) {
    setTimeout(() => {
      console.log(capturedI)
    }, 100)
  })(i)
}
// output: 1 → 2 → 3

Here, an immediately invoked function expression (IIFE) takes the current i as an argument. capturedI is that function's own local variable, and a fresh copy gets created on every call — producing the same effect as let.

Closures Aren't Just for Debugging — They're a Practical Tool

The most common practical use of closures is data privacy. JavaScript doesn't have a built-in private keyword the way some other languages do, so closures have long been the go-to technique for simulating private variables:

js
function createAccount(initialBalance) {
  let balance = initialBalance
 
  return {
    deposit(amount) {
      balance += amount
      return balance
    },
    withdraw(amount) {
      if (amount > balance) {
        throw new Error('Insufficient balance')
      }
      balance -= amount
      return balance
    },
    getBalance() {
      return balance
    },
  }
}
 
const account = createAccount(1000)
account.deposit(500) // 1500
account.getBalance() // 1500
account.balance // undefined — completely inaccessible from outside

There's no way to reach balance itself from the outside — you can only manipulate it indirectly through deposit, withdraw, and getBalance, the functions that all carry the same backpack.

Another common use case is memoization: using a closure to cache results you've already computed, so you don't redo the work:

js
function memoize(fn) {
  const cache = new Map()
 
  return (arg) => {
    if (cache.has(arg)) {
      return cache.get(arg)
    }
    const result = fn(arg)
    cache.set(arg, result)
    return result
  }
}

Every time the returned function gets called, it reaches the same cache through its closure — which is exactly why the caching logic keeps working across multiple calls.

What to Watch Out for When Using Closures

Closures are convenient, but because they keep variables alive longer than you'd expect, they can also plant landmines if you're not careful:

  • Memory retention: As long as some function is still "carrying" a variable, that variable won't get garbage collected (GC). If a closure accidentally holds onto a large object or a DOM node, and that closure sticks around for a long time (say, attached to a global event listener), it can end up wasting memory unnecessarily.
  • A closure carries the actual variable, not a snapshot of its value: this is also the root cause behind the loop bug from earlier — when multiple functions share the same scope, they're always mutating the same piece of data, never independent copies of their own.