Closures
A closure is a function together with the variables it was written next to. That is the whole definition, and it is less exotic than it sounds - every function in JavaScript is one. What is worth learning is exactly what is kept, for how long, and what that buys you.
What is actually kept
A closure does not copy values. It keeps the binding alive - the variable itself, not a snapshot of what was in it.
function counter() {
let count = 0; // one binding, per call to counter()
return {
increment() { return ++count; },
current() { return count; },
};
}
const a = counter();
const b = counter();
console.log(a.increment()); // 1
console.log(a.increment()); // 2
console.log(b.increment()); // 1 - b has its own count
console.log(a.current()); // 2
Open this example in the editor and change it. Nothing you type is saved.
count is gone from the stack the moment counter() returns, yet increment still reads and writes it. The engine noticed that the returned functions refer to count and kept the scope alive on the heap for as long as they are reachable.
Two calls to counter() make two independent count bindings. That is why closures give you instances without classes.
The "binding not snapshot" part is what makes the loop bug possible:
const withVar = [];
for (var i = 0; i < 3; i++) withVar.push(() => i);
console.log(withVar.map((f) => f())); // [3, 3, 3] - one shared binding
const withLet = [];
for (let j = 0; j < 3; j++) withLet.push(() => j);
console.log(withLet.map((f) => f())); // [0, 1, 2] - a new binding per pass
Open this example in the editor and change it. Nothing you type is saved.
Three closures, one var binding, which by the end holds 3. The let version is specified to create a fresh binding each iteration, so each closure keeps a different one.
Privacy that the language enforces
Before #private fields, a closure was the only real privacy in JavaScript, and it is still the simplest.
function createAccount(openingPence = 0) {
let balance = openingPence; // unreachable from outside
const history = [];
return {
deposit(pence) {
if (!Number.isInteger(pence) || pence <= 0) throw new RangeError('bad amount');
balance += pence;
history.push({ kind: 'deposit', pence });
return balance;
},
get balance() { return balance; },
get history() { return [...history]; }, // a copy, so callers cannot push
};
}
const acc = createAccount(1000);
acc.deposit(500);
console.log(acc.balance); // 1500
console.log(Object.keys(acc)); // ['deposit', 'balance', 'history']
// - and no 'balance' VARIABLE among them
console.log(acc.history.length); // 1
acc.history.push({ fake: true }); // pushes into the copy
console.log(acc.history.length); // 1 - the real one is untouched
Open this example in the editor and change it. Nothing you type is saved.
There is no way to reach balance from outside. Not by key, not by Object.getOwnPropertyNames, not by a debugger expression in the enclosing scope. The only access is through the functions that were written next to it.
Note the get history() returning a copy. Returning the array itself would hand out a reference to private state, which is the commonest hole in an otherwise correct encapsulation.
The patterns worth recognising
Partial application - fix some arguments now, supply the rest later.
const multiplyBy = (factor) => (n) => n * factor;
const double = multiplyBy(2);
const triple = multiplyBy(3);
console.log([1, 2, 3].map(double)); // [2, 4, 6]
console.log(triple(5)); // 15
Open this example in the editor and change it. Nothing you type is saved.
Memoisation - a cache that lives with the function.
function memoise(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn(...args));
return cache.get(key);
};
}
let calls = 0;
const slowSquare = (n) => { calls++; return n * n; };
const fast = memoise(slowSquare);
console.log(fast(9), fast(9), fast(9)); // 81 81 81
console.log(calls); // 1
Open this example in the editor and change it. Nothing you type is saved.
Once - a guard that cannot be bypassed.
function once(fn) {
let done = false;
let result;
return (...args) => {
if (!done) { done = true; result = fn(...args); }
return result;
};
}
const init = once(() => { console.log('initialising'); return 42; });
console.log(init(), init(), init());
// 'initialising' printed once, then: 42 42 42
Open this example in the editor and change it. Nothing you type is saved.
Module-level state - the quiet, everyday one. Every const at the top of a module that a function below refers to is a closure, and that is how configuration, caches and connection pools are shared.
Memory
Closures keep their scope alive. That is the feature, and it is also the leak.
function leaky() {
const huge = new Array(1e6).fill('data'); // about 8MB
// This closure refers only to a number - but in most engines the
// whole scope is kept, so `huge` cannot be collected either.
return () => huge.length;
}
function tidy() {
const huge = new Array(1e6).fill('data');
const size = huge.length; // take what you need
return () => size; // huge is now collectable
}
Open this example in the editor and change it. Nothing you type is saved.
Engines have got better at this - V8 does analyse which variables a closure actually uses - but the analysis is per scope, not per variable, in enough cases that the habit is worth keeping: extract the value you need rather than closing over the structure it came from.
The other leak is the handler nobody removed:
function attach(element, rows) {
const handler = () => console.log(rows.length);
element.addEventListener('click', handler);
// Without this, `rows` stays alive for as long as the element does,
// and the element stays alive for as long as the listener does.
return () => element.removeEventListener('click', handler);
}
Open this example in the editor and change it. Nothing you type is saved.
Returning the unsubscribe function from wherever you subscribe is the single habit that prevents most listener leaks. It also makes the lifetime visible in the calling code, which is where it belongs.
A worked example
// A rate limiter: state that must be private, per instance, and must
// survive between calls. Closures are the natural shape for all three.
function rateLimiter({ max = 3, windowMs = 1000 } = {}) {
const hits = new Map(); // key -> array of timestamps
return function allow(key, now = Date.now()) {
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= max) {
hits.set(key, recent);
return { ok: false, retryInMs: windowMs - (now - recent[0]) };
}
recent.push(now);
hits.set(key, recent);
return { ok: true, remaining: max - recent.length };
};
}
const allow = rateLimiter({ max: 3, windowMs: 1000 });
const start = 10000;
console.log(allow('aisha', start)); // { ok: true, remaining: 2 }
console.log(allow('aisha', start + 100)); // { ok: true, remaining: 1 }
console.log(allow('aisha', start + 200)); // { ok: true, remaining: 0 }
console.log(allow('aisha', start + 300)); // { ok: false, retryInMs: 700 }
// A different key has its own budget.
console.log(allow('noshad', start + 300)); // { ok: true, remaining: 2 }
// And once the window has passed, the first caller is allowed again.
console.log(allow('aisha', start + 1100)); // { ok: true, remaining: 1 }
// remaining is 1, not 2: the hit at start + 200 is still inside the
// window at start + 1100, so only two of the three slots are free.
// A second limiter is genuinely independent - its own hits Map.
const other = rateLimiter({ max: 1, windowMs: 1000 });
console.log(other('aisha', start)); // { ok: true, remaining: 0 }
console.log(other('aisha', start + 1)); // { ok: false, retryInMs: 999 }
console.log(allow('noshad', start + 400)); // { ok: true, remaining: 1 }
Open this example in the editor and change it. Nothing you type is saved.
hits is created once per limiter and reached by every call to allow. Nothing outside can inspect it, corrupt it, or share it between limiters by accident. Passing now as a parameter rather than calling Date.now() inside is a small discipline that makes the whole thing testable without faking the clock - which is why the example above can assert exact numbers.
Every function is a closure
const greeting = 'Hello';
function greet(name) { return greeting + ' ' + name; }
Open this example in the editor and change it. Nothing you type is saved.
greet closes over greeting. Nobody calls that a closure in conversation, but it is the same mechanism - which is the point. There is no separate closure feature to learn. There is lexical scope, and the fact that a function outliving its scope keeps that scope alive.
When it goes wrong
| Symptom | Cause |
|---|---|
| Every callback in a loop sees the last value | var in the loop header; use let |
| Two instances share state | The state was created outside the factory |
| Memory grows and never falls | A closure keeps a large structure alive |
| Removing a listener does nothing | removeEventListener was given a different function object |
| A memoised function never hits its cache | The key is built from an object with unstable key order |
| A cache grows without limit | No eviction; use a bounded Map or WeakMap |
| Private state leaked | A getter returned the array or object itself, not a copy |
| A value is stale | The closure captured a snapshot where a getter was wanted |
A check you can run
Open DevTools, go to the Memory panel, and take a heap snapshot. Then run this in the console:
globalThis.keep = [];
for (let i = 0; i < 100; i++) {
const big = new Array(100000).fill(i);
globalThis.keep.push(() => big.length);
}
Open this example in the editor and change it. Nothing you type is saved.
Take a second snapshot and compare. The retained size has grown by tens of megabytes, held by one hundred closures that each need a single number. Now change big.length to a const size = big.length captured outside the arrow, re-run, and compare again. The difference is the lesson, and it is the kind of thing you only really believe once you have watched the number.