Closures are one of the most important — and most misunderstood — concepts in JavaScript. Every JavaScript developer encounters them daily, often without realizing it. Once you truly understand closures, you'll write better code, understand more libraries, and finally be able to explain that tricky interview question with confidence.
What is a Closure?
A closure is a function that remembers the variables from its outer scope even after that outer function has returned. In other words, a closure gives you access to an outer function's scope from an inner function.
This happens naturally in JavaScript because functions carry a reference to their surrounding lexical environment — not a copy of it, but the actual environment.
function makeCounter() {
let count = 0; // This variable lives in makeCounter's scope
return function() { // This inner function is a closure
count++;
return count;
};
}
const counter = makeCounter(); // makeCounter has returned...
console.log(counter()); // 1 // ...but count still exists!
console.log(counter()); // 2
console.log(counter()); // 3
After makeCounter() returns, you'd expect count to be garbage collected. But it isn't — because the inner function holds a reference to it. The inner function is the closure, and it "closes over" the count variable.
Understanding Lexical Scope
Closures are built on lexical scoping: functions can access variables in the scope where they were defined, not where they are called.
const greeting = 'Hello';
function outer() {
const name = 'Alice';
function inner() {
// inner() can access both name and greeting
// because of where it was defined (inside outer)
console.log(`${greeting}, ${name}!`);
}
return inner;
}
const sayHello = outer();
sayHello(); // 'Hello, Alice!'
// Even if called in a completely different context:
const name = 'Bob'; // different name in outer scope
sayHello(); // Still 'Hello, Alice!' — not 'Bob'
The inner() function remembers the name from when and where it was created, not the name in the calling scope.
Scope Chain
JavaScript looks up variable values by walking up the scope chain until it finds a match (or reaches the global scope).
const a = 1;
function level1() {
const b = 2;
function level2() {
const c = 3;
function level3() {
const d = 4;
// level3 can access a, b, c, d
console.log(a + b + c + d); // 10
}
level3();
}
level2();
}
level1();
Practical Use Cases for Closures
1. Data Privacy / Encapsulation
JavaScript doesn't have native private variables on plain objects (before ES2022 class fields). Closures were the original solution:
function createBankAccount(initialBalance) {
let balance = initialBalance; // private — not accessible from outside
return {
deposit(amount) {
if (amount <= 0) throw new Error('Amount must be positive');
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) throw new Error('Insufficient funds');
balance -= amount;
return balance;
},
getBalance() {
return balance;
}
};
}
const account = createBankAccount(100);
console.log(account.getBalance()); // 100
account.deposit(50);
console.log(account.getBalance()); // 150
account.withdraw(30);
console.log(account.getBalance()); // 120
// Cannot access balance directly:
console.log(account.balance); // undefined
2. Factory Functions
function createMultiplier(factor) {
return (number) => number * factor;
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
const tenTimes = createMultiplier(10);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(tenTimes(5)); // 50
// Another practical example: API endpoint builder
function createApiClient(baseUrl, apiKey) {
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
};
return {
async get(path) {
const res = await fetch(`${baseUrl}${path}`, { headers });
return res.json();
},
async post(path, body) {
const res = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
return res.json();
}
};
}
const api = createApiClient('https://api.example.com', 'secret-key-123');
const users = await api.get('/users');
3. Memoization
Closures are perfect for caching expensive computations:
function memoize(fn) {
const cache = new Map(); // closure over this cache
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log('Cache hit');
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
function slowFibonacci(n) {
if (n <= 1) return n;
return slowFibonacci(n - 1) + slowFibonacci(n - 2);
}
const fastFibonacci = memoize(slowFibonacci);
fastFibonacci(40); // computed once
fastFibonacci(40); // 'Cache hit' — instant
4. Event Handlers with Context
function setupButton(buttonId, message) {
const button = document.getElementById(buttonId);
button.addEventListener('click', function() {
// This function closes over 'message'
alert(message);
});
}
setupButton('btn1', 'Hello from button 1!');
setupButton('btn2', 'Hello from button 2!');
// Each button remembers its own message
5. Partial Application and Currying
// Partial application: fix some arguments, return a function for the rest
function partial(fn, ...presetArgs) {
return function(...laterArgs) {
return fn(...presetArgs, ...laterArgs);
};
}
function add(a, b, c) { return a + b + c; }
const add5 = partial(add, 5);
const add5and10 = partial(add, 5, 10);
console.log(add5(3, 2)); // 10
console.log(add5and10(1)); // 16
// Currying: transform f(a, b, c) into f(a)(b)(c)
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return (...moreArgs) => curried(...args, ...moreArgs);
};
}
const curriedAdd = curry((a, b, c) => a + b + c);
curriedAdd(1)(2)(3); // 6
curriedAdd(1, 2)(3); // 6
curriedAdd(1)(2, 3); // 6
6. Module Pattern
// IIFE-based module (pre-ES modules pattern)
const userModule = (function() {
let users = []; // private
function findById(id) { // private helper
return users.find(u => u.id === id);
}
return {
addUser(user) {
users.push(user);
},
getUser(id) {
return findById(id);
},
getAllUsers() {
return [...users]; // return a copy, not a reference
}
};
})();
userModule.addUser({ id: 1, name: 'Alice' });
console.log(userModule.getUser(1)); // { id: 1, name: 'Alice' }
console.log(userModule.users); // undefined — private!
Classic Closure Pitfall: Loops
The most famous closure bug involves loops with var:
// ❌ Bug: all buttons alert "5"
for (var i = 0; i < 5; i++) {
const button = document.createElement('button');
button.textContent = `Button ${i}`;
button.addEventListener('click', function() {
alert(i); // closes over the SAME i
});
document.body.appendChild(button);
}
// When clicked, i is already 5 (loop finished)
// ✅ Fix 1: use let (block-scoped, new binding each iteration)
for (let i = 0; i < 5; i++) {
const button = document.createElement('button');
button.addEventListener('click', function() {
alert(i); // each iteration has its own i
});
document.body.appendChild(button);
}
// ✅ Fix 2: use an IIFE to capture i
for (var i = 0; i < 5; i++) {
(function(capturedI) {
const button = document.createElement('button');
button.addEventListener('click', function() {
alert(capturedI);
});
document.body.appendChild(button);
})(i);
}
// ✅ Fix 3: use forEach
[0, 1, 2, 3, 4].forEach(i => {
const button = document.createElement('button');
button.addEventListener('click', () => alert(i));
document.body.appendChild(button);
});
Memory Considerations
Closures can cause memory leaks if not managed carefully. The captured variables persist as long as the closure exists:
// ⚠️ Potential memory leak
function addHandler() {
const largeData = new Array(1000000).fill('data'); // large allocation
document.getElementById('btn').addEventListener('click', function() {
// This closure holds largeData in memory for as long as the listener exists
console.log(largeData.length);
});
}
// ✅ Better: only capture what you need
function addHandler() {
const largeData = new Array(1000000).fill('data');
const dataLength = largeData.length; // capture only what's needed
document.getElementById('btn').addEventListener('click', function() {
console.log(dataLength);
// largeData can now be garbage collected
});
}
// ✅ Clean up listeners when done
const handler = () => console.log('clicked');
button.addEventListener('click', handler);
// Later:
button.removeEventListener('click', handler);
Closures in Modern JavaScript
Even with ES2022 private class fields (#field), closures are everywhere in modern JavaScript:
// React hooks use closures extensively
function Counter() {
const [count, setCount] = useState(0);
// This function closes over 'count'
const handleClick = () => {
setCount(count + 1);
};
// useEffect callback closes over dependencies
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return <button onClick={handleClick}>{count}</button>;
}
// Stale closure problem in useEffect
function Example({ id }) {
const [data, setData] = useState(null);
useEffect(() => {
// This callback closes over 'id' at the time it was created
fetchData(id).then(setData);
}, [id]); // Re-run when id changes — keeps closure fresh
}
Key Takeaways
- A closure is a function that retains access to its outer scope's variables even after the outer function returns.
- Closures are created every time a function is created in JavaScript — it's automatic, not opt-in.
- They're the foundation of private state, factory functions, memoization, and the module pattern.
- The classic loop bug (with
var) is solved by usingletor arrow functions withforEach. - Be mindful of memory: closures keep their captured variables alive. Remove event listeners when no longer needed.
// Closure in one sentence:
// A function that carries its birthplace environment with it.
function outer() {
const secret = 42;
return () => secret; // closure: remembers 'secret' forever
}
const getSecret = outer();
getSecret(); // 42 — even though outer() is long gone
Understanding closures unlocks a deeper understanding of how JavaScript actually works — scope, execution context, garbage collection, and the design patterns that power every major library. Once you see them, you'll start noticing closures everywhere.
Free Developer Tools
If you found this article helpful, check out DevToolkit — 40+ free browser-based developer tools with no signup required.
Popular tools: JSON Formatter · Regex Tester · JWT Decoder · Base64 Encoder
🛒 Get the DevToolkit Starter Kit on Gumroad — source code, deployment guide, and customization templates.
Top comments (0)