If you've been writing JavaScript for a while, you've definitely used closures - even if you didn't know it. Every callback, every event handler, every setTimeout involves closures. But what exactly is a closure, and why does JavaScript work this way?
The textbook definition is usually something like: "A closure is a function bundled together with references to its surrounding state (lexical environment)." But that doesn't explain the why or the how.
Let's dive deep into what closures actually are at the engine level, why they exist, and what problems they solve.
The Problem Closures Solve: Functions Need Context
Here's a simple example:
function createCounter() {
let count = 0;
return function increment() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
This works, but... how? When createCounter() finishes executing, shouldn't count be destroyed? The function has returned, its execution context is gone. So how does increment still have access to count?
This is the fundamental question closures answer.
How JavaScript Executes Functions: The Call Stack and Execution Contexts
Before we can understand closures, we need to understand how JavaScript runs code.
When you call a function, JavaScript creates an Execution Context - a structure containing everything needed to run that function:
- Variable Environment: Where local variables live
- Lexical Environment: Where the function "looks up" variables (more on this soon)
-
thisbinding: Whatthispoints to - Outer Environment Reference: A link to the parent scope
These execution contexts are pushed onto the Call Stack. When a function returns, its execution context is popped off the stack.
function outer() {
let x = 10;
inner();
}
function inner() {
console.log('Hello');
}
outer();
Call Stack during execution:
3. inner() execution context ← Currently executing
2. outer() execution context
1. Global execution context
When inner() returns, its context is popped:
2. outer() execution context ← Back to executing outer
1. Global execution context
When outer() returns, its context is popped:
1. Global execution context ← Back to global
So if execution contexts are destroyed when functions return, how do closures work?
The Secret: Lexical Environments Live on the Heap
Here's the key insight: Execution Contexts live on the call stack (temporary), but Lexical Environments live on the heap (permanent, until garbage collected).
When you create a function, that function gets a hidden internal property called [[Environment]]. This property stores a reference to the Lexical Environment where the function was created.
Let's trace through our counter example:
function createCounter() {
let count = 0;
return function increment() {
count++;
return count;
};
}
const counter = createCounter();
Step 1: Call createCounter()
JavaScript creates an Execution Context for createCounter:
Call Stack:
createCounter() execution context
Global execution context
Heap:
Lexical Environment A {
Environment Record: { count: 0 }
Outer Reference: → Global Lexical Environment
}
Step 2: Create the increment function
When JavaScript encounters function increment(), it creates a function object and sets its [[Environment]] property:
Heap:
Lexical Environment A {
Environment Record: { count: 0 }
Outer Reference: → Global
}
Function Object: increment
[[Environment]]: → Lexical Environment A // This is the closure!
The function object now has a permanent reference to Lexical Environment A.
Step 3: Return from createCounter()
const counter = createCounter();
The execution context for createCounter() is popped off the call stack. But Lexical Environment A stays in memory because the increment function still references it!
Call Stack:
Global execution context
Heap:
Lexical Environment A {
Environment Record: { count: 0 }
Outer Reference: → Global
}
Function Object: increment
[[Environment]]: → Lexical Environment A
Global Lexical Environment {
Environment Record: {
counter: → Function Object: increment
}
}
Step 4: Call counter()
When you call counter() (which is increment), JavaScript:
- Creates a new Execution Context for
increment - Sets its Lexical Environment's outer reference to
increment.[[Environment]](Lexical Environment A) - Executes
count++
To resolve count, JavaScript:
- Looks in the current Environment Record (increment's local scope) - not found
- Follows the outer reference to Lexical Environment A - found!
count: 0 - Increments it to
1 - Returns
1
Heap:
Lexical Environment A {
Environment Record: { count: 1 } // Updated!
Outer Reference: → Global
}
Lexical Environment B (for this call to increment) {
Environment Record: { } // No local variables
Outer Reference: → Lexical Environment A
}
The next time you call counter(), it creates a new Execution Context, but that context's outer reference still points to the same Lexical Environment A, where count is now 1. So it increments to 2.
This is a closure: The increment function "closes over" the variables in Lexical Environment A, keeping them alive even after createCounter has returned.
Multiple Closures, Shared Environment
Here's where it gets interesting:
function createCounter() {
let count = 0;
return {
increment() {
count++;
return count;
},
decrement() {
count--;
return count;
},
getCount() {
return count;
}
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1
console.log(counter.getCount()); // 1
All three methods share the same Lexical Environment:
Heap:
Lexical Environment A {
Environment Record: { count: 1 }
}
Function: increment
[[Environment]]: → Lexical Environment A
Function: decrement
[[Environment]]: → Lexical Environment A
Function: getCount
[[Environment]]: → Lexical Environment A
They all close over the same count variable because they were all created in the same scope.
Multiple Closures, Separate Environments
But if you call createCounter again, you get a completely independent closure:
const counter1 = createCounter();
const counter2 = createCounter();
console.log(counter1.increment()); // 1
console.log(counter2.increment()); // 1
console.log(counter1.increment()); // 2
console.log(counter2.getCount()); // 1
Each call to createCounter() creates a new Lexical Environment:
Heap:
Lexical Environment A {
Environment Record: { count: 2 }
}
Lexical Environment B {
Environment Record: { count: 1 }
}
counter1.increment [[Environment]]: → Lexical Environment A
counter2.increment [[Environment]]: → Lexical Environment B
Each counter is completely isolated. This is why closures are powerful for creating private state.
The Loop Problem Revisited
Remember this classic problem?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3
Now you can see exactly why this happens. Let's trace the Lexical Environments:
During the loop:
Heap:
Global Lexical Environment {
Environment Record: { i: 0 } // Then 1, then 2, then 3
}
Arrow Function 1
[[Environment]]: → Global Lexical Environment
Arrow Function 2
[[Environment]]: → Global Lexical Environment
Arrow Function 3
[[Environment]]: → Global Lexical Environment
All three arrow functions close over the same Global Lexical Environment. When they execute 100ms later, they all read i from that environment, where it's now 3.
With let:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2
As we discussed in the var/let/const article, let in a loop creates a new Lexical Environment for each iteration:
Heap:
Lexical Environment (iteration 0) {
Environment Record: { i: 0 }
}
Arrow Function 1
[[Environment]]: → Lexical Environment (iteration 0)
Lexical Environment (iteration 1) {
Environment Record: { i: 1 }
}
Arrow Function 2
[[Environment]]: → Lexical Environment (iteration 1)
Lexical Environment (iteration 2) {
Environment Record: { i: 2 }
}
Arrow Function 3
[[Environment]]: → Lexical Environment (iteration 2)
Each arrow function closes over a different environment with a different i.
Before let existed, the solution was to create a new scope manually:
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 100);
})(i);
}
// Output: 0, 1, 2
The IIFE (Immediately Invoked Function Expression) creates a new Lexical Environment for each iteration, with its own j parameter. Each arrow function closes over its own j.
Practical Use Cases: Why Closures Matter
1. Data Privacy
JavaScript doesn't have private class fields (well, it does now with #, but closures were the original solution):
function createBankAccount(initialBalance) {
let balance = initialBalance; // Private variable
return {
deposit(amount) {
if (amount > 0) {
balance += amount;
return balance;
}
},
withdraw(amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return balance;
}
},
getBalance() {
return balance;
}
};
}
const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined - can't access directly!
There's no way to access balance except through the methods. It's truly private because it only exists in the Lexical Environment that the methods close over.
2. Function Factories
function createMultiplier(multiplier) {
return function(number) {
return number * multiplier;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
Each returned function closes over its own multiplier value:
Heap:
Lexical Environment A { multiplier: 2 }
Lexical Environment B { multiplier: 3 }
double [[Environment]]: → Lexical Environment A
triple [[Environment]]: → Lexical Environment B
3. Callbacks and Event Handlers
function setupButton(buttonId, message) {
const button = document.getElementById(buttonId);
button.addEventListener('click', function() {
console.log(message);
});
}
setupButton('btn1', 'Button 1 clicked');
setupButton('btn2', 'Button 2 clicked');
Each click handler closes over its own message variable, even though setupButton has long since returned.
4. Memoization
function memoize(fn) {
const cache = {};
return function(...args) {
const key = JSON.stringify(args);
if (key in cache) {
return cache[key];
}
const result = fn(...args);
cache[key] = result;
return result;
};
}
const expensiveOperation = memoize((n) => {
console.log('Computing...');
return n * n;
});
console.log(expensiveOperation(5)); // Computing... 25
console.log(expensiveOperation(5)); // 25 (cached, no log)
The returned function closes over cache, which persists between calls.
Memory Leaks: When Closures Go Wrong
Closures keep Lexical Environments alive. This is usually good, but can cause memory leaks:
function attachHandler() {
const data = new Array(1000000).fill('leak');
document.getElementById('button').addEventListener('click', function() {
console.log('Clicked');
});
}
attachHandler();
Even though the click handler doesn't use data, the entire Lexical Environment (including data) is kept alive because the handler closes over it.
Modern engines optimize this: V8 and other engines detect when variables aren't actually used in closures and don't keep them in memory. But it's still something to be aware of.
Fix: Don't capture what you don't need:
function attachHandler() {
const data = new Array(1000000).fill('leak');
// Process data here if needed
const result = processData(data);
document.getElementById('button').addEventListener('click', function() {
console.log('Clicked', result); // Only closes over result, not data
});
}
Closures and this: A Common Pitfall
const obj = {
name: 'Alice',
greet() {
setTimeout(function() {
console.log('Hello, ' + this.name);
}, 100);
}
};
obj.greet(); // Hello, undefined
The problem: The function passed to setTimeout doesn't close over this. this is not a variable - it's determined by how a function is called, not where it's defined.
Solution 1: Arrow functions (which don't have their own this):
const obj = {
name: 'Alice',
greet() {
setTimeout(() => {
console.log('Hello, ' + this.name);
}, 100);
}
};
obj.greet(); // Hello, Alice
Solution 2: Capture this in a variable (the pre-ES6 way):
const obj = {
name: 'Alice',
greet() {
const self = this;
setTimeout(function() {
console.log('Hello, ' + self.name);
}, 100);
}
};
obj.greet(); // Hello, Alice
Now self is a regular variable that the closure can capture.
How Engines Optimize Closures
You might think: "Doesn't keeping all these Lexical Environments around use a lot of memory?"
Modern JavaScript engines are very smart about closures:
1. Dead Variable Elimination: If a variable in a closure's environment is never used, the engine doesn't keep it in memory.
function outer() {
let used = 1;
let notUsed = new Array(1000000);
return function() {
return used;
};
}
The engine sees that notUsed is never accessed in the closure and doesn't keep it alive.
2. Sharing Environments: If multiple functions are created in the same scope and use the same variables, they can share an Environment Record.
3. Scope Analysis: During compilation, the engine figures out exactly which variables each function needs and creates optimized structures.
Understanding Closures Through the Spec
The ECMAScript specification defines closures through these mechanisms:
§9.1 Environment Records: The spec defines exactly how variables are stored and accessed.
§9.4 Execution Contexts: Defines how contexts reference their outer environments.
§10.2 Function Objects: Specifies that functions have a [[Environment]] internal slot.
When you call a function:
- Create a new Function Environment Record
- Set its outer environment reference to the function's
[[Environment]] - This creates the closure - the function can now access variables from where it was defined
Summary: Closures are Lexical Scope + Memory
A closure isn't magic - it's a natural consequence of how JavaScript implements lexical scope:
- Functions store a reference to the Lexical Environment where they were created (
[[Environment]]) - Lexical Environments live on the heap, not the call stack
- When a function executes, it can access variables from its
[[Environment]], even if the original execution context is gone - Multiple functions created in the same scope share the same Lexical Environment
- Each call to an outer function creates a new, independent Lexical Environment
Closures enable:
- Private variables (data hiding)
- Function factories (partial application)
- Callbacks that remember context
- Memoization and caching
Understanding closures at this level helps you:
- Debug scope-related bugs
- Avoid memory leaks
- Write more efficient code
- Understand React hooks, module patterns, and other advanced patterns
Next time you write a callback or return a function from a function, you'll know exactly what's happening in memory - and why your variables are still accessible.
Top comments (2)
Wow, you did an amazing work here you break down closures in details and all of the pitfalls of closure this is a full package this is great, I really liked it.
So much appreciate it, thank you so much for your comment!