JavaScript Closures
A closure is a function bundled with a reference to the variables of the scope in which it was created. The function “remembers” those variables even after the outer function has returned. Closures power everything from setTimeout callbacks to module privacy to React hooks — they’re the single most important concept in JavaScript after the language fundamentals.
The One-Line Definition
A closure is a function that has access to its outer (enclosing) scope, even after that scope has finished executing.
function makeGreeter(name) {
return function () {
console.log(`Hello, ${name}!`);
};
}
const greetAda = makeGreeter("Ada");
greetAda(); // "Hello, Ada!" ← `name` is still accessible
makeGreeter returned. Its name parameter should be gone. But the inner function closed over name, so it’s kept alive on the heap.
makeGreeter("Ada") called
┌────────────────────┐
│ name = "Ada" │◄────┐
└────────────────────┘ │
│ │ inner function holds
│ returns │ a reference to `name`
▼ │
┌────────────────────┐ │
│ () => log(name) │─────┘
└────────────────────┘
Even after makeGreeter returns, the box at top
stays in memory because the inner function still
references it.
Why It Matters — Three Patterns
1. Data Privacy
A function can expose a small API while keeping its internal state hidden:
function makeCounter() {
let count = 0; // private — only the closure can see this
return {
increment: () => ++count,
decrement: () => --count,
value: () => count,
};
}
const c = makeCounter();
c.increment();
c.increment();
c.value(); // 2
c.count; // undefined — actually inaccessible
No prototype trickery, no Symbol, no #private. Just lexical scope doing the work.
2. Function Factories
Pre-configure a function once, call it many times:
function multiplier(factor) {
return (x) => x * factor;
}
const double = multiplier(2);
const triple = multiplier(3);
double(5); // 10
triple(5); // 15
double and triple are two different closures, each remembering its own factor.
3. Callbacks That Remember Context
This is the everyday case — every addEventListener, setTimeout, .then, and .map callback is a closure.
function attachLogger(prefix) {
button.addEventListener("click", (e) => {
console.log(`[${prefix}] clicked at`, e.x, e.y);
});
}
attachLogger("HEADER");
attachLogger("FOOTER");
Each handler keeps its own prefix.
The Loop Bug — Resolved
The “classic” closure-in-loop bug returns at the right time:
// ❌ var — single binding shared by all callbacks
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3
// ✅ let — fresh binding each iteration
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2
With let, each loop iteration creates a new lexical binding for i, so each closure captures a different one. This is exactly why let exists.
Closures Close Over Variables, Not Values
A closure references the variable, not its value at creation time. If the variable changes, the closure sees the new value:
let name = "Ada";
const greet = () => console.log(`Hi, ${name}`);
greet(); // "Hi, Ada"
name = "Lin";
greet(); // "Hi, Lin" ← sees the new value
This is a feature, but also a gotcha. If you want to snapshot a value, copy it into a local:
let name = "Ada";
const greet = ((snapshot) => () => console.log(`Hi, ${snapshot}`))(name);
greet(); // "Hi, Ada"
name = "Lin";
greet(); // "Hi, Ada" ← snapshot
Module Pattern (Classic IIFE)
Before ES modules, closures were the only way to keep helpers private:
const Calculator = (function () {
let history = []; // private
function record(op, result) {
history.push({ op, result, ts: Date.now() });
}
return {
add(a, b) {
const r = a + b;
record("add", r);
return r;
},
history: () => [...history], // expose a copy
};
})();
Calculator.add(2, 3); // 5
Calculator.history(); // [{ op: "add", result: 5, ts: ... }]
Calculator.history; // undefined — pure private
Modern code uses real modules, but the pattern still shows up in build outputs, polyfills, and library internals.
Memoization — Cache Inside a Closure
function memoize(fn) {
const cache = new Map(); // private cache, lives in closure
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 slowSquare = x => { console.log("computing"); return x * x; };
const fastSquare = memoize(slowSquare);
fastSquare(5); // "computing" → 25
fastSquare(5); // → 25 (cached, no log)
fastSquare(6); // "computing" → 36
The cache Map is invisible to callers — they only see the wrapped function.
Currying — Series of Closures
const curry = fn =>
function curried(...args) {
if (args.length >= fn.length) return fn(...args);
return (...more) => curried(...args, ...more);
};
const sum3 = curry((a, b, c) => a + b + c);
sum3(1)(2)(3); // 6
sum3(1, 2)(3); // 6
sum3(1)(2, 3); // 6
Each partial-application call returns a new closure that remembers the accumulated arguments.
A Realistic Example — Debounce
Debouncing means: “ignore rapid repeated calls; only fire after the caller goes quiet for N ms”. The implementation is a small but textbook closure:
function debounce(fn, delayMs) {
let timer = null; // captured by the returned function
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delayMs);
};
}
// Usage: only fire after user stops typing for 300 ms
const onSearch = debounce(query => {
console.log("searching for", query);
}, 300);
input.addEventListener("input", e => onSearch(e.target.value));
timer lives in the closure. Every call to the returned function sees and updates it.
Memory and Closures
Closures keep referenced variables alive on the heap. Usually that’s fine — but if you hold a closure that captures a huge object (e.g. a DOM node, a large dataset), the GC can’t collect it.
function heavy() {
const huge = new Array(1_000_000).fill("data");
return function tiny() {
return huge.length; // captures `huge` forever
};
}
const t = heavy(); // huge stays in memory as long as t lives
If you only need one piece of huge, capture just that piece:
function lighter() {
const huge = new Array(1_000_000).fill("data");
const size = huge.length;
return () => size; // captures only `size`, `huge` is GC-eligible
}
Tip: Closures don’t leak by themselves — they leak when you forget about callbacks (e.g., never removing an event listener). Be especially careful with long-lived closures over DOM elements.
Common Mistakes
// ❌ Returning the inner function with `this`
function makeBound() {
this.value = 42;
return function () { return this.value; };
}
const fn = makeBound();
fn(); // undefined — `this` rebinds at call site
// ✅ Use arrow to keep outer `this`
function makeBound() {
this.value = 42;
return () => this.value;
}
// ❌ Capturing `i` with var in a loop — see Loop Bug section above
// ❌ Thinking closures snapshot values (they don't)
let n = 1;
const get = () => n;
n = 99;
get(); // 99, not 1
Best Practices Checklist
- ✅ Use closures for privacy when class fields (
#priv) feel heavy - ✅ Use closures to capture per-iteration state —
let, notvar - ✅ Use closures in factory functions to pre-configure callbacks
- ✅ Watch what you capture — large objects in long-lived closures stay alive
- ✅ Remove event listeners (and other long-lived callbacks) you no longer need
- ❌ Don’t assume closures snapshot values — they reference variables
- ❌ Don’t capture
thisin a regular inner function — use an arrow
What’s Next?
Closures rely on lexical scope. Their close cousin — hoisting — is about when a name becomes available within its scope. Next chapter clears up the rules for var, let, const, function declarations, and the temporal dead zone.