JavaScript Closures: The Definitive Guide (with 7 Production Patterns)
Closures are the most-asked JavaScript interview question on Earth and also the concept most people memorize a definition of without truly understanding. They are not a quirk, not a trick, not a syntactic feature. Closures are simply what happens when lexical scope meets first-class functions.
In this article, we will pin down what closures actually are, watch the engine create one, and walk through seven production patterns where closures save you real code.
This is Part 4 of the JavaScript Fundamentals series. Read Part 1 (execution contexts) and Part 3 (lexical scope) first.
The definition that actually helps #
A closure is a function bundled with a reference to its outer lexical environment. The function "closes over" the variables it uses from its surrounding scope — and those variables stay alive as long as the function does, even if the outer function that created them has already returned.
That is the whole concept. Everything else is consequences.
Watching a closure form #
function makeCounter() {
let count = 0;
return function inc() {
count += 1;
return count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3
Step by step:
makeCounter()is called. A function execution context is pushed onto the call stack (Part 1). Its lexical environment holdscount = 0.makeCountercreates anincfunction. By lexical scope,inc's outer environment ismakeCounter's.makeCounterreturnsinc. Its execution context is popped off the stack.- But
countdoes not get garbage-collected! Why? Becauseinc(still alive via thecountervariable) holds a reference to its outer environment, which holdscount. - Every call to
counter()walks the scope chain, findscount, increments it, and returns the new value.
The outer function has finished executing, but its local variable lives on as long as the returned function exists. That is a closure.
The mental model #
Most people picture a closure as the function. It is actually the function and a snapshot reference to its surrounding scope. The function is the visible part; the captured environment is the iceberg under the water.
┌────────────────────┐
inc ──▶│ function inc() │ ────▶ outer env of inc
│ count += 1; │ ┌────────────────┐
│ return count; │ │ count: 0 │
│ } │ │ outer: global │
└────────────────────┘ └────────────────┘
Each call to makeCounter() creates a new outer environment with its own count. Two different counters do not share state:
const a = makeCounter();
const b = makeCounter();
a(); a(); a(); // 3
b(); // 1 — independent
That independence is what makes closures the foundation of encapsulation in JavaScript.
Pattern 1: Private state without classes #
Before JavaScript had class (and even now, often a better fit), closures gave you encapsulation:
function createAccount(initial) {
let balance = initial;
return {
deposit(amount) { balance += amount; },
withdraw(amount) { balance -= amount; },
get balance() { return balance; },
};
}
const acct = createAccount(100);
acct.deposit(50);
acct.balance; // 150
acct.balance = -1; // setter not defined — silently ignored
// no way to reach the inner `balance` directly
The inner balance is genuinely inaccessible from outside. Classes with private fields (#balance) achieve the same thing now, but closure-based factories are still common because they compose better and have no this binding to worry about.
Pattern 2: Function factories #
When you find yourself writing several near-identical functions that differ only in one constant, return them from a factory:
function multiplyBy(factor) {
return (n) => n * factor;
}
const double = multiplyBy(2);
const triple = multiplyBy(3);
double(5); // 10
triple(5); // 15
Each returned arrow function closes over its own factor. This is the foundation of currying — see our currying deep-dive.
Pattern 3: Memoization #
A closure is the cleanest place to keep a memoization cache:
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const slowFib = (n) => (n < 2 ? n : slowFib(n - 1) + slowFib(n - 2));
const fastFib = memoize(slowFib);
fastFib(40); // instant
The cache is private to this wrapper and shared across every call to the returned function. No global Map, no class, no boilerplate.
Pattern 4: Debounce and throttle #
Debounce — "only fire after the user stops typing" — is a closure capturing a timer ID:
function debounce(fn, ms = 300) {
let t;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), ms);
};
}
const search = debounce((q) => fetchResults(q), 400);
inputEl.addEventListener('input', e => search(e.target.value));
The inner function closes over t and fn. Each input event resets the timer; the wrapped function only runs after a quiet period. We have a whole article on debouncing with switchMap for the RxJS-flavored variant.
Pattern 5: Iterators and generators (the manual version) #
Closures can carry iteration state without needing the formal iterator protocol:
function makeRange(start, end) {
let current = start;
return {
next() {
if (current >= end) return { done: true };
return { value: current++, done: false };
},
};
}
const it = makeRange(0, 3);
it.next(); // { value: 0, done: false }
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { done: true }
The current variable persists across next() calls — same mechanism as makeCounter. Generators (Part 15) give you a cleaner syntax for this same idea.
Pattern 6: Once-only initialization #
A classic helper: "call this expensive function at most once, cache the result forever":
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
result = fn.apply(this, args);
called = true;
}
return result;
};
}
const getConfig = once(() => fetch('/config').then(r => r.json()));
getConfig(); // fetches
getConfig(); // returns cached promise instantly
Libraries like lodash ship this as a one-liner because every codebase eventually needs it.
Pattern 7: Module pattern (the IIFE classic) #
Before ES modules existed, the only way to get a private namespace was an immediately-invoked function expression:
const Counter = (function () {
let count = 0;
return {
inc() { return ++count; },
reset() { count = 0; },
};
})();
Counter.inc(); // 1
The outer function runs once, returns the public API, and its count lives inside the closure of inc and reset. ES modules made this obsolete for top-level code, but the pattern still appears in single-file widgets and library bundles.
The classic for loop trap #
The most-asked closure interview question:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Logs: 3, 3, 3
Why three 3s and not 0, 1, 2? Because var is function-scoped (Part 2). There is exactly one i for the whole loop. All three arrow functions close over the same binding. By the time the timers fire, the loop has finished and i is 3.
The fix:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Logs: 0, 1, 2
let is block-scoped, so each iteration creates a new i. Each callback closes over a different i. Each binding's value freezes at the moment that iteration ran.
This one example explains why we replaced var with let in modern JavaScript.
What closures cost #
Closures keep their captured environment alive as long as the closure exists. That is power but also a memory consideration. If you create thousands of closures that each capture large objects, those objects stay in memory.
function handler(bigData) {
// bigData is captured in EVERY closure created below
document.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
console.log(bigData.id); // closure holds bigData alive
});
});
}
If bigData was a multi-megabyte structure and you only needed bigData.id, extract just what you need first:
function handler(bigData) {
const id = bigData.id; // bigData can now be GCed once handler returns
document.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => console.log(id));
});
}
We go deeper into this in Part 9 (Memory and Garbage Collection) and Part 10 (WeakMap / WeakSet / WeakRef).
Common gotchas #
Gotcha 1: this is not closed over (unless you use arrow functions).
function Timer() {
this.seconds = 0;
setInterval(function () {
this.seconds++; // "this" is global / undefined, not the Timer
}, 1000);
}
Use arrow functions (which inherit this lexically) or .bind(this). We cover this in detail in Part 5.
Gotcha 2: Closures + mutable shared state = subtle bugs.
const handlers = [];
let config = { ready: false };
for (let i = 0; i < 3; i++) {
handlers.push(() => console.log(config.ready));
}
config.ready = true;
handlers.forEach(h => h()); // true true true — they see live config
Closures capture variables by reference (for object types — the same reference). If you mutate the object, every closure sees the change. Sometimes desired, sometimes a bug.
Gotcha 3: Don't accidentally close over event from a loop.
buttons.forEach(btn => {
btn.addEventListener('click', event => {
setTimeout(() => doSomething(event), 1000); // capturing the event
});
});
The event object is fine to capture inside event handlers, but holding it across async boundaries means it can't be GCed. For a long-running app, prefer extracting event.target.value (or whatever you actually need) and capturing that.
Gotcha 4: Performance — too many closures in hot loops.
for (let i = 0; i < 1_000_000; i++) {
arr.push((x) => x + i); // 1M closures, 1M captured i's
}
Usually fine. Sometimes a perf issue. Modern engines are very good at optimizing closures — but if profiling fingers a hot loop creating millions of closures, that is a place to refactor.
Recap #
- A closure = a function + a reference to its outer lexical environment.
- Outer variables stay alive as long as the closure does.
- Each invocation of an outer function creates a new closure with its own captured environment.
- Closures enable: private state, factories, memoization, debounce/throttle, iterators, once-only initialization, the IIFE module pattern.
- The classic
for var ibug is a closure issue — solved bylet. - Closures hold their captured environment alive; for big captures, extract just what you need.
Next up: The this Keyword: 5 Binding Rules — what this actually refers to in every situation, why arrow functions are different, and the rules that resolve every edge case.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


