Closures in JavaScript
Learn what closures are, how lexical scoping works, and how to use closures for private state, memoization, and module patterns.
A closure is one of the most fundamental — and most useful — concepts in JavaScript. Once you truly understand it, patterns like module encapsulation, memoization, and partial application click into place naturally. Closures aren’t a special syntax or keyword; they’re a consequence of how JavaScript resolves variable names.
Lexical Scoping
JavaScript uses lexical (static) scoping: a function’s scope is determined by where it is written in the source code, not where it is called at runtime. This means inner functions always have access to variables declared in any outer function, simply because of their position in the code. The engine looks up the scope chain at definition time, not at call time.
function outer() {
const message = "hello from outer";
function inner() {
// `message` is accessible here because inner is written inside outer
console.log(message);
}
inner(); // "hello from outer"
}
outer();
What Makes a Closure
A closure forms when an inner function is returned (or passed elsewhere) and continues to reference variables from its outer scope after the outer function has finished executing. Normally, local variables are discarded when a function returns. But when an inner function closes over them, the JavaScript engine keeps those variables alive as long as the inner function exists.
function makeGreeter(greeting) {
// `greeting` lives in makeGreeter's local scope
return function (name) {
// This inner function closes over `greeting` —
// it will still be accessible after makeGreeter returns
return `${greeting}, ${name}!`;
};
}
const sayHello = makeGreeter("Hello");
const sayHi = makeGreeter("Hi");
console.log(sayHello("Alice")); // "Hello, Alice!"
console.log(sayHi("Bob")); // "Hi, Bob!"
// makeGreeter has long since returned, but each closure keeps its own `greeting` alive
Each call to makeGreeter produces an independent closure with its own captured greeting. The two closures don’t share state — they each close over a separate variable.
Practical Pattern: Counter with Private State
Closures are the classical way to create private state in JavaScript without classes. The counter variable is completely inaccessible from outside — only the returned methods can read or modify it. This gives you the same encapsulation guarantee as a class with private fields, built purely from function scope.
function createCounter(initialValue = 0) {
let count = initialValue; // private — no external code can reach this directly
return {
increment() { count += 1; },
decrement() { count -= 1; },
reset() { count = initialValue; },
value() { return count; },
};
}
const counter = createCounter(10);
counter.increment();
counter.increment();
counter.decrement();
console.log(counter.value()); // 11
// `count` is unreachable from outside
console.log(counter.count); // undefined — not exposed
This is structurally identical to what classes do with private fields (#count), but built purely from closures.
The Module Pattern
Before ES modules existed, developers used an IIFE (Immediately Invoked Function Expression) to create a module-like scope. The IIFE runs once, sets up private state, and returns a public API object — a closure over the private variables. You’ll encounter this pattern in older libraries and bundled code.
const userStore = (() => {
// private state — inaccessible from outside
const users = new Map();
let nextId = 1;
// public API — the returned object is what the outside world sees
return {
add(name, email) {
const id = nextId++;
users.set(id, { id, name, email });
return id;
},
get(id) {
return users.get(id) ?? null;
},
count() {
return users.size;
},
};
})();
const id = userStore.add("Alice", "alice@example.com");
console.log(userStore.get(id)); // { id: 1, name: 'Alice', email: 'alice@example.com' }
console.log(userStore.count()); // 1
console.log(userStore.users); // undefined — the Map is private
Memoization
Because closures keep their outer scope alive, they’re perfect for caching. A memoization wrapper closes over a Map that persists between calls. On the first call with a given set of arguments, the result is computed and cached. On subsequent calls with the same arguments, the cached result is returned immediately — no recomputation.
function memoize(fn) {
const cache = new Map(); // this Map persists for the lifetime of the memoized function
return function (...args) {
const key = JSON.stringify(args); // serialize args as the cache key
if (cache.has(key)) {
return cache.get(key); // cache hit — skip recomputation
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// Expensive recursive computation without memoization
function slowFibonacci(n) {
if (n <= 1) return n;
return slowFibonacci(n - 1) + slowFibonacci(n - 2);
}
const fastFibonacci = memoize(slowFibonacci);
console.time("first call");
console.log(fastFibonacci(40)); // 102334155
console.timeEnd("first call"); // ~800ms — computed from scratch
console.time("cached call");
console.log(fastFibonacci(40)); // 102334155
console.timeEnd("cached call"); // ~0ms — returned from cache
The Classic Loop Closure Bug
This trips up nearly every JavaScript developer at least once. The root cause is var’s function scoping: all iterations of the loop share the exact same variable. By the time the setTimeout callbacks run (after the loop finishes), the shared variable holds its final value.
// BUG: var is function-scoped — all callbacks share the same `i`
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Prints: 3, 3, 3 — NOT 0, 1, 2
Fix 1: use let (creates a new block-scoped binding per iteration — the simplest fix):
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Prints: 0, 1, 2
Fix 2: use an IIFE to capture the value explicitly (useful when you must support older environments without let):
for (var i = 0; i < 3; i++) {
(function (captured) {
// `captured` is a new local variable per iteration — not shared
setTimeout(() => console.log(captured), 100);
})(i);
}
// Prints: 0, 1, 2
Partial Application with Closures
Partial application means pre-filling some of a function’s arguments and returning a new function that accepts the rest. Closures make this trivial: the pre-filled arguments are captured in the closure and merged with the later arguments when the function is eventually called.
function multiply(a, b) {
return a * b;
}
function partial(fn, ...presetArgs) {
return function (...laterArgs) {
return fn(...presetArgs, ...laterArgs); // presetArgs is closed over here
};
}
const double = partial(multiply, 2);
const triple = partial(multiply, 3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
// Real-world use: pre-configure a fetch helper with a base URL
const apiGet = partial(fetch, "https://api.example.com");
// apiGet("/users") is equivalent to fetch("https://api.example.com", "/users")
Common Pitfalls
Accidental memory leaks — closures keep their outer scope alive as long as the closure itself is referenced. If a large object is in scope and the closure is never cleaned up (e.g., an event listener that’s never removed), that object can’t be garbage-collected even after it’s no longer needed.
function processLargeData() {
const hugeArray = new Array(1_000_000).fill(0); // ~8 MB
// This closure captures hugeArray. If handleClick is never removed,
// hugeArray stays in memory for the entire lifetime of the page.
document.addEventListener("click", function handleClick() {
console.log(hugeArray.length);
});
}
// Fix: remove event listeners when no longer needed
// document.removeEventListener("click", handleClick);
Fix: remove event listeners when no longer needed, or restructure to avoid closing over data you don’t actually need inside the callback.
Key Takeaways
- A closure captures references to outer variables, not their values at a point in time — the variable can still change.
- Each closure call gets its own independent set of closed-over variables.
- Use
letin loops to give each iteration its own binding. - Closures enable private state, memoization, partial application, and the module pattern without any special syntax.