JavaScript Closures & Scope: The Complete Guide for 2026
Closures are one of the most powerful and frequently misunderstood concepts in JavaScript. They appear in every interview, underpin nearly every design pattern, and silently power features you use every day — from event handlers to React hooks. This guide goes deeper than "a function that remembers its outer variables": you will learn exactly how JavaScript scope works, why closures exist, and how to use them to write cleaner, more private, and more performant code.
Table of Contents
- What Is Scope?
- var vs let vs const
- The Scope Chain
- Lexical Scope Explained
- What Are Closures?
- Closures in Loops
- Practical Closure Patterns
- Module Pattern Using Closures
- Event Handlers and Closures
- Closures and Memory
- IIFE (Immediately Invoked Function Expressions)
- Closures in Modern JavaScript
- Common Interview Questions
- Debugging Closure Issues in DevTools
- Frequently Asked Questions
1. What Is Scope?
Scope determines where variables are accessible in your code. JavaScript has three types of scope: global, function, and block scope.
// Global scope -- accessible everywhere
const appName = 'DevToolbox';
function greet() {
// Function scope -- only inside this function
const message = 'Hello';
console.log(appName); // Works: global is accessible
}
greet();
// console.log(message); // ReferenceError: not accessible outside
if (true) {
let blockScoped = 'only here'; // Block scope (let/const)
var notBlockScoped = 'escapes!'; // Function scope (var)
}
// console.log(blockScoped); // ReferenceError
console.log(notBlockScoped); // "escapes!" -- var ignores blocks
Global variables are discouraged because any code can overwrite them. Function scope isolates variables to their function. Block scope (ES6) with let/const confines variables to the nearest {} block — including if, for, and standalone blocks.
2. var vs let vs const
The three declaration keywords differ in scope, hoisting, and reassignability.
// var: function-scoped, hoisted (initialized as undefined)
console.log(a); // undefined (hoisted)
var a = 10;
// let: block-scoped, hoisted but NOT initialized (Temporal Dead Zone)
// console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 20;
// const: block-scoped, hoisted but NOT initialized, cannot reassign
const c = 30;
// c = 40; // TypeError: Assignment to constant variable
The Temporal Dead Zone (TDZ) is the period between the start of a block and the let/const declaration where accessing the variable throws a ReferenceError. With var, the variable is undefined during this period instead.
function tdzExample() {
// TDZ starts here for `name`
console.log(typeof name); // ReferenceError with let
let name = 'DevToolbox'; // TDZ ends
}
function varExample() {
console.log(typeof name); // "undefined" -- no TDZ with var
var name = 'DevToolbox';
}
Best practice: Use const by default, let when you need reassignment, and avoid var entirely.
3. The Scope Chain
When JavaScript encounters a variable, it walks up the scope chain — from inner to outer scopes — until it finds the variable or reaches the global scope.
const global = 'I am global';
function outer() {
const outerVar = 'I am outer';
function inner() {
const innerVar = 'I am inner';
// inner can access ALL variables in the chain:
console.log(innerVar); // "I am inner"
console.log(outerVar); // "I am outer"
console.log(global); // "I am global"
}
inner();
// console.log(innerVar); // ReferenceError -- chain only goes UP
}
outer();
The scope chain only goes upward. An outer function cannot access variables from an inner function. This one-way visibility is fundamental to how closures create privacy.
4. Lexical Scope Explained
JavaScript uses lexical scope (static scope): scope is determined by where you write the code, not where or how the function is called.
const language = 'JavaScript';
function getLanguage() {
return language; // Looks up to where getLanguage was DEFINED
}
function wrapper() {
const language = 'Python'; // Does NOT affect getLanguage
return getLanguage(); // Still returns 'JavaScript'
}
console.log(wrapper()); // "JavaScript"
getLanguage was defined in the global scope where language is 'JavaScript'. The scope was locked in at definition time, not call time. This is the critical insight that makes closures possible.
function createGreeter(greeting) {
return function(name) {
return `${greeting}, ${name}!`; // `greeting` is from the outer scope
};
}
const hello = createGreeter('Hello');
const hola = createGreeter('Hola');
console.log(hello('Alice')); // "Hello, Alice!"
console.log(hola('Bob')); // "Hola, Bob!"
5. What Are Closures?
A closure is a function bundled with references to its surrounding lexical environment. In simpler terms: a closure remembers the variables from the scope where it was created, even after that scope has finished executing.
function createCounter() {
let count = 0; // Lives on after createCounter returns
return function() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
// console.log(count); // ReferenceError -- truly private
When createCounter() returns, its local count would normally be destroyed. But because the returned function closes over count, JavaScript keeps it alive. Each call to counter() accesses and modifies the same count.
function makeAdder(x) {
return function(y) { return x + y; };
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);
console.log(add5(3)); // 8
console.log(add10(3)); // 13 -- different closures, different `x` values
6. Closures in Loops (Classic Interview Question)
This is the most famous closure gotcha.
// THE BUG: var is function-scoped, one `i` shared by all callbacks
for (var i = 0; i < 3; i++) {
setTimeout(function() { console.log(i); }, 100);
}
// Output: 3, 3, 3 (NOT 0, 1, 2)
// FIX 1: Use `let` -- creates a new binding per iteration
for (let i = 0; i < 3; i++) {
setTimeout(function() { console.log(i); }, 100);
}
// Output: 0, 1, 2
// FIX 2: IIFE -- captures current value as a parameter
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(function() { console.log(j); }, 100);
})(i);
}
// FIX 3: Factory function
function createLogger(value) {
return function() { console.log(value); };
}
for (var i = 0; i < 3; i++) {
setTimeout(createLogger(i), 100);
}
With var, there is only one i shared by all callbacks. By the time the timeouts fire, the loop has finished and i is 3. With let, each iteration gets its own binding.
7. Practical Closure Patterns
Data Privacy
function createBankAccount(initialBalance) {
let balance = initialBalance; // Private -- no way to access directly
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); // 150
account.withdraw(30); // 120
// account.balance; // undefined -- truly private
Factory Functions
function createValidator(regex, errorMessage) {
return function(value) {
return regex.test(value)
? { valid: true, error: null }
: { valid: false, error: errorMessage };
};
}
const validateEmail = createValidator(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email');
const validatePhone = createValidator(/^\d{10}$/, 'Phone must be 10 digits');
console.log(validateEmail('user@example.com')); // { valid: true, error: null }
console.log(validatePhone('12345')); // { valid: false, error: "Phone must be 10 digits" }
Memoization
function memoize(fn) {
const cache = {}; // Closed over -- persists between calls
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 expensiveSquare = memoize((n) => n * n);
expensiveSquare(4); // Computes: 16
expensiveSquare(4); // Cache hit: 16
8. Module Pattern Using Closures
Before ES6 import/export, the module pattern combined an IIFE with closures to create a public API with private internals.
const UserModule = (function() {
const users = []; // Private
let nextId = 1; // Private
function findIndex(id) { // Private
return users.findIndex(u => u.id === id);
}
return {
add(name, email) {
const user = { id: nextId++, name, email };
users.push(user);
return user;
},
remove(id) {
const idx = findIndex(id);
return idx !== -1 ? users.splice(idx, 1)[0] : null;
},
getAll() { return [...users]; },
count() { return users.length; }
};
})();
UserModule.add('Alice', 'alice@example.com');
UserModule.add('Bob', 'bob@example.com');
console.log(UserModule.count()); // 2
// UserModule.users; // undefined (private)
// UserModule.findIndex; // undefined (private)
9. Event Handlers and Closures
Every time you attach a callback that references an outer variable, you are using a closure.
// Debounce: a classic closure-based utility
function debounce(fn, delay) {
let timeoutId; // Closed over -- persists between calls
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
const input = document.getElementById('search');
input?.addEventListener('input', debounce(function(e) {
console.log('Searching for:', e.target.value);
}, 300));
Common Pitfall: Stale Closures
function createTracker() {
let clicks = 0;
const message = `You have ${clicks} clicks`; // Captured at creation time!
return {
click() { clicks++; },
report() { return message; }, // Always "0 clicks" (stale!)
reportFixed() { return `You have ${clicks} clicks`; } // Computed at call time
};
}
const tracker = createTracker();
tracker.click(); tracker.click();
tracker.report(); // "You have 0 clicks" -- stale closure
tracker.reportFixed(); // "You have 2 clicks" -- correct
10. Closures and Memory
Closures keep enclosed variables alive. This is usually desirable, but can cause unintentional memory retention.
// Problem: entire hugeArray is kept alive
function processData() {
const hugeArray = new Array(1000000).fill('data');
return function() { return hugeArray.length; };
}
// Fix: capture only what you need
function processDataFixed() {
const hugeArray = new Array(1000000).fill('data');
const length = hugeArray.length;
return function() { return length; }; // hugeArray can be GC'd
}
Watch for memory leaks in:
- Event listeners that are never removed and capture large scopes
- Timers (
setInterval) that run forever and close over growing data - Callbacks stored in long-lived arrays or maps
- Detached DOM nodes referenced by closures
// Leak: interval never cleared, results grows forever
function startPolling(url) {
const results = [];
setInterval(async () => {
results.push(await fetch(url).then(r => r.json()));
}, 5000);
}
// Fix: return a cleanup function
function startPollingFixed(url) {
const results = [];
const id = setInterval(async () => {
results.push(await fetch(url).then(r => r.json()));
}, 5000);
return () => clearInterval(id); // Allows garbage collection
}
11. IIFE (Immediately Invoked Function Expressions)
An IIFE runs the moment it is defined, creating a private scope that prevents global namespace pollution.
// Basic IIFE
(function() {
const secret = 'hidden from outside';
console.log(secret);
})();
// Arrow function IIFE
(() => {
const alsoPrivate = 'also hidden';
})();
// IIFE with return value
const result = (function() {
return 10 + 20;
})();
console.log(result); // 30
// IIFE with parameters (common in jQuery-era code)
(function($) {
// $ is guaranteed to be jQuery here
$('.selector').hide();
})(jQuery);
ES6 modules have largely replaced IIFEs for creating private scopes, but IIFEs remain useful for one-off initialization and understanding older code.
12. Closures in Modern JavaScript
Arrow Functions and this
Arrow functions capture this from the enclosing scope — itself a form of closure.
function Timer() {
this.seconds = 0;
// Arrow captures `this` from Timer
setInterval(() => { this.seconds++; }, 1000);
}
// Without arrow function, you need the classic `self = this` closure:
function TimerOld() {
this.seconds = 0;
const self = this;
setInterval(function() { self.seconds++; }, 1000);
}
Closures in Classes
class EventEmitter {
#listeners = new Map();
on(event, callback) {
if (!this.#listeners.has(event)) this.#listeners.set(event, []);
this.#listeners.get(event).push(callback);
// Return an unsubscribe closure
return () => {
const cbs = this.#listeners.get(event);
const idx = cbs.indexOf(callback);
if (idx > -1) cbs.splice(idx, 1);
};
}
emit(event, ...args) {
(this.#listeners.get(event) || []).forEach(cb => cb(...args));
}
}
const emitter = new EventEmitter();
const unsub = emitter.on('data', (msg) => console.log(msg));
emitter.emit('data', 'Hello!'); // "Hello!"
unsub(); // Unsubscribes via closure
emitter.emit('data', 'Gone'); // (nothing)
React Hooks: Closures in Action
function Counter() {
const [count, setCount] = useState(0);
// This callback closes over current `count`
const increment = () => setCount(count + 1);
// useEffect cleanup is a closure
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, [count]);
return <button onClick={increment}>{count}</button>;
}
13. Common Interview Questions About Closures
Q1: What does this output?
function createFunctions() {
const result = [];
for (var i = 0; i < 5; i++) {
result.push(function() { return i; });
}
return result;
}
const fns = createFunctions();
console.log(fns[0](), fns[2](), fns[4]());
// Answer: 5, 5, 5 -- all share the same `i`. Fix: use `let`.
Q2: Implement once()
function once(fn) {
let called = false, result;
return function(...args) {
if (!called) { called = true; result = fn.apply(this, args); }
return result;
};
}
const init = once(() => { console.log('Init!'); return 42; });
init(); // "Init!" -> 42
init(); // 42 (no log)
Q3: Private state with closures
function createStack() {
const items = [];
return {
push(item) { items.push(item); },
pop() { return items.pop(); },
peek() { return items[items.length - 1]; },
get size() { return items.length; }
};
}
const stack = createStack();
stack.push('a'); stack.push('b');
console.log(stack.peek()); // "b"
console.log(stack.size); // 2
// stack.items; // undefined (private)
Q4: Fix the setTimeout loop
// Prints 3,3,3 -- fix to print 0,1,2:
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), i * 100);
// Fix 1: `let`
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), i * 100);
// Fix 2: IIFE
for (var i = 0; i < 3; i++) ((j) => setTimeout(() => console.log(j), j * 100))(i);
// Fix 3: setTimeout's third argument
for (var i = 0; i < 3; i++) setTimeout((j) => console.log(j), i * 100, i);
14. Debugging Closure Issues in DevTools
Chrome DevTools provides powerful ways to inspect closures at runtime.
Inspect Closures in the Scope Panel
- Set a breakpoint inside the inner function (or use
debugger). - When execution pauses, open the Scope panel in the Sources tab.
- You will see Closure listed as a scope, showing closed-over variables and their values.
function outer() {
const secret = 42;
return function inner() {
debugger; // Check Scope panel here
console.log(secret);
};
}
outer()();
// Scope panel shows: Closure (outer): { secret: 42 }
Other Debugging Strategies
- console.dir(fn) — shows
[[Scopes]]property listing all closure scopes - Memory snapshots — use the Memory tab to find closures retaining large objects
- Performance profiling — profile allocation to find closure hot paths
const double = (function(factor) {
return function(n) { return n * factor; };
})(2);
console.dir(double);
// [[Scopes]]: Closure: { factor: 2 }
Frequently Asked Questions
What is a closure in JavaScript?
A closure is a function that retains access to variables from its outer (enclosing) scope even after the outer function has finished executing. Every function in JavaScript forms a closure over its lexical environment. This means an inner function can read and modify variables declared in the outer function long after the outer function has returned. Closures are the foundation of data privacy, factory functions, and the module pattern.
What is the difference between lexical scope and dynamic scope?
Lexical scope (static scope) means variable access is determined by where the code is written, not where the function is called. JavaScript uses lexical scope exclusively. Dynamic scope determines variable access based on the call stack at runtime. In JavaScript, a function always has access to the variables in the scope where it was defined, regardless of where it is invoked.
Why does var behave differently from let and const in loops?
var is function-scoped, so a var inside a for loop is shared across all iterations. All closures reference the same variable, which holds the final value after the loop. let and const are block-scoped, creating a new binding per iteration. Each closure gets its own independent copy. This is the classic closure-in-a-loop gotcha that appears frequently in interviews.
Do closures cause memory leaks in JavaScript?
Closures do not inherently cause memory leaks, but they can prevent garbage collection of variables they reference. If a closure keeps a reference to a large object or DOM element no longer needed, that memory cannot be reclaimed. Common scenarios include event handlers capturing large scopes, timers that are never cleared, and closures stored in long-lived data structures. To avoid leaks, nullify unneeded references, remove event listeners, and clear intervals.
What is the module pattern and how does it use closures?
The module pattern uses an IIFE to create a private scope, then returns an object with only the public methods. Private variables inside the IIFE remain accessible to the returned methods through closures but are hidden from outside code. This was the primary encapsulation technique before ES6 modules (import/export). The pattern is still useful for understanding closures and appears in many legacy codebases.
Conclusion
Closures are not a niche concept — they are the mechanism behind nearly every pattern in JavaScript. When you debounce an input, memoize a function, create a React hook, or attach an event handler, you are using closures. Understanding how scope and closures work transforms you from someone who copies patterns to someone who invents them.
Key takeaways: (1) scope is determined by where you write the code (lexical scope), not where you call it, (2) closures keep outer variables alive as long as the inner function exists, and (3) use let/const instead of var to avoid the classic loop trap.
Related Resources
- JavaScript Promises & Async/Await Guide — async patterns that build on closure concepts
- JavaScript Event Loop Guide — understand how async callbacks interact with closures
- JavaScript ES6+ Features Guide — modern syntax including let, const, arrow functions, and modules
- DOM Manipulation Guide — event handling patterns that rely on closures
Keep learning: Closures connect to every major JavaScript topic. Explore Promises & Async/Await to see closures in async code, or read our ES6+ Features Guide for the modern syntax that makes closures cleaner and safer.