B13 · Part 1 · Beginner
Closures
The function that walked out with its birthplace still attached.
12On this page
- Nothing gets turned on
- Watch it happen in space
- Now step through it line by line
- Why the variable survives at all
- A live link, not a snapshot
- One call, one closure
- Five real jobs closures do
- The loop trap, from the closure side
- See what the callback captured
- Closures and memory — the part that bites in production
- Seeing one for yourself
- The traps, collected
Nothing gets turned on
A closure is not syntax you write. It is what you already have. Every function, the moment it is created, keeps a link to the environment it was created in — that is just the outer reference from the execution context chapter, sitting there doing nothing interesting.
It only becomes visible when the function outlives the call that made it. Then the link is the only thing keeping that environment alive, and suddenly everyone has a name for it.
function makeCounter() {
let count = 0; // a local. Should die when makeCounter returns.
return () => ++count; // but this little function still points at it
}
const next = makeCounter();
next(); // 1
next(); // 2 — count outlived the call that created it
Watch it happen in space
The flat version of this picture is what causes the confusion — it
looks like count gets copied into the function. It does
not. Drag the scene, or use the sliders, and step through: the call
frame goes away, the environment stays, because something in
front of it is still holding on.
Global environment
makeCounter = fn
makeCounter() environment
count = 0
still reachable
a second environment
count = 0
next() — the returned function
code + a link back
drag to rotate
Now step through it line by line
The 3D scene shows the idea. This player runs the same program the way
the engine does, one line at a time. Press Next until
counter() returns: its frame turns dashed because the call
has ended, yet it is not thrown away.
- Nothing has run yet. Functions written with
functionalready exist in memory: JavaScript sets them up before the first line runs.
Next line to run: const next = counter(); (line 6)
Code
function counter() {let n = 0;return () => ++n;}- next
const next = counter(); next();next();
Printed output
nothing printed yet
What the engine holds in memory
Frames
- counter
Objects
defined at the top level
- a variable points to an object
- a function remembers the frame it was made in
- dashed frame: its call ended, but something still needs it
Why the variable survives at all
In the execution context chapter you learned that a call gets an execution context, and that the context leaves the stack when the call finishes. That is still true — of the stack frame. The frame is bookkeeping: where to return to, what is running. It pops.
The environment — the box holding the actual variables — lives somewhere else, on the heap, and it follows one rule only:
Rule JavaScript keeps anything that is still
reachable. A closure is a reference, and a reference is
reachability. There is no special "closure keep-alive" mechanism — the
ordinary garbage collector simply cannot collect a box that something
still points at.
Which is why closures are not free, and why the memory section below is not an afterthought.
A live link, not a snapshot
The most common wrong mental model is that the inner function copies the values it needs. Two functions made in the same call share one environment, and that settles it:
function makeBox() {
let value = "first";
return {
read: () => value,
write: (v) => { value = v; },
};
}
const box = makeBox();
console.log(box.read()); // "first"
box.write("second");
console.log(box.read()); // a copy would still say "first"
"second". read and write are
two different functions closing over the same box, so a
change made through one is visible through the other. If closures
copied values, that would be impossible.
Here is the same idea with a bank balance. Both methods point at one
frame, and nothing outside can reach balance except through
them.
- Nothing has run yet. Functions written with
functionalready exist in memory: JavaScript sets them up before the first line runs.
Next line to run: const bank = makeBank(); (line 14)
Code
function makeBank() {let balance = 100;return {deposit(x) {balance += x;return balance;},peek() {return balance;},};}- next
const bank = makeBank(); bank.deposit(50);console.log(bank.peek());
Printed output
nothing printed yet
What the engine holds in memory
Frames
- makeBank
Objects
defined at the top level
- a variable points to an object
- a function remembers the frame it was made in
- dashed frame: its call ended, but something still needs it
One call, one closure
function makeCounter() {
let count = 0;
return () => ++count;
}
const a = makeCounter();
const b = makeCounter();
a(); a(); a();
b();
console.log("a:", a(), " b:", b()); // what happens?
a: 4 b: 2. Two calls, two environments, two completely
separate counts. The closure belongs to the call, never to
the function you wrote — which is exactly what the last step of the 3D
scene was showing.
Dry run: what each call actually incremented
| Call | Which environment | count before | count after |
|---|---|---|---|
a() — 1st | a's (from the first makeCounter()) | 0 | 1 |
a() — 2nd | a's | 1 | 2 |
a() — 3rd | a's | 2 | 3 |
b() — 1st | b's (from the second makeCounter()) | 0 | 1 |
a() in the final log | a's | 3 | 4 |
b() in the final log | b's | 1 | 2 |
a and b never touch the same count
— each call to makeCounter() built a fresh environment, so
there were always two separate boxes, not one shared between them.
Step through the same program and compare the two kept frames: each
call to makeCounter() left behind its own box.
- Nothing has run yet. Functions written with
functionalready exist in memory: JavaScript sets them up before the first line runs.
Next line to run: const a = makeCounter(); (line 9)
Code
function makeCounter() {let n = 0;return function inc() {n += 1;return n;};}- next
const a = makeCounter(); const b = makeCounter();a();a();b();console.log(a(), b());
Printed output
nothing printed yet
What the engine holds in memory
Frames
- makeCounter
Objects
defined at the top level
- a variable points to an object
- a function remembers the frame it was made in
- dashed frame: its call ended, but something still needs it
Five real jobs closures do
Interviews rarely ask "what is a closure". They ask you to build one of these.
1. Factories — functions that build functions
function multiplierOf(factor) {
return (n) => n * factor;
}
const double = multiplierOf(2);
const triple = multiplierOf(3);
double(5); // 10 — "factor" is remembered inside double, permanently
2. Privacy — state no outside code can reach
function createAccount(startingBalance) {
let balance = startingBalance; // truly private — no this.balance to poke at
return {
deposit: (n) => (balance += n),
getBalance: () => balance,
};
}
const acct = createAccount(100);
acct.deposit(50);
acct.getBalance(); // 150
acct.balance; // undefined — there is no way in
This is the oldest privacy mechanism in JavaScript, and still the
simplest. Classes got a real one in ES2022 —
#private fields — but a closure needs no syntax at all.
3. Memoize — a cache only the function can see
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(...args);
cache.set(key, result);
return result;
};
}
let calls = 0;
const slowSquare = memoize((n) => { calls++; return n * n; });
slowSquare(5);
slowSquare(5);
slowSquare(5);
console.log("real calls:", calls); // what happens?
1 — the underlying function runs once per distinct set of
arguments. cache is closed over by the returned function
and nothing else, so no outside code can reach or corrupt it.
4. Once — work that happens a single time
function once(fn) {
let called = false, result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}
let inits = 0;
const init = once(() => { inits++; return "ready"; });
console.log(init(), init(), init());
console.log("actual inits:", inits); // what happens?
ready ready ready, then 1. Every call after
the first returns the cached result without re-running
fn — the standard shape behind "run this setup exactly
once, however many times it is asked for."
5. Debounce and throttle — a timer nobody outside can see
function debounce(fn, delay) {
let timer; // private to the returned function
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, interval) {
let ready = true;
return function (...args) {
if (!ready) return;
ready = false;
fn.apply(this, args);
setTimeout(() => { ready = true; }, interval);
};
}
Rule Debounce waits for a pause and runs once
at the end — a search box waiting until they stop typing. Throttle
runs immediately, then enforces a cooldown — a scroll handler firing
at most once every N ms, the whole time they scroll.
The loop trap, from the closure side
The var, let and const chapter showed this as a question about how many bindings a loop creates. From here it is the same fact, viewed from the other end: all three callbacks closed over the same environment, so they all read the same box — and by the time any of them ran, the loop had finished writing to it.
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0); // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j), 0); // 0 1 2
One shared box versus a fresh box per iteration. Nothing about the callbacks changed.
Step through both versions. With var there is one
i in the global scope. With let every pass gets
its own scope, and each callback keeps one of them alive.
- Nothing has run yet.
Next line to run: var fns = []; (line 1)
Code
- next
var fns = []; for (var i = 0; i < 3; i++) {fns.push(() => i);}console.log(fns[0](), fns[1](), fns[2]());
Printed output
nothing printed yet
What the engine holds in memory
Frames
- fns
- undefined
- i
- undefined
Objects
no objects yet
- a variable points to an object
- a function remembers the frame it was made in
- dashed frame: its call ended, but something still needs it
- Nothing has run yet.
Next line to run: const fns = []; (line 1)
Code
- next
const fns = []; for (let i = 0; i < 3; i++) {fns.push(() => i);}console.log(fns[0](), fns[1](), fns[2]());
Printed output
nothing printed yet
What the engine holds in memory
Frames
no variables yet
Objects
no objects yet
- a variable points to an object
- a function remembers the frame it was made in
- dashed frame: its call ended, but something still needs it
See what the callback captured
The same loop twice, one keyword apart. Watch how many bindings each version creates — that is the whole difference.
Bindings in scope
Queued callbacks
Closures and memory — the part that bites in production
A closure is a reference, and a reference stops collection. Almost every JavaScript memory leak is a closure that outlived its usefulness while still holding something large.
// The listener never gets removed, so the closure lives forever —
// and it is holding a reference to a huge object it barely uses.
function attach(node, hugeDataset) {
node.addEventListener("click", () => {
console.log("clicked", hugeDataset.length); // keeps hugeDataset alive
});
}
// Same shape, worse: an interval no one clears keeps its closure,
// its closure keeps the node, and the node may already be off the page.
setInterval(() => update(detachedNode), 1000);
-
Remove listeners when the thing they belong to goes away, or
use an
AbortControllersignal to drop them all at once. - Clear intervals and timeouts. An uncleared interval is an immortal closure.
- Do not close over more than you need. Pull out the one field you actually use rather than capturing the whole object.
Engines are smarter than the naive model: V8 captures only the variables a closure actually mentions, not the entire scope. But the ones it does capture are held completely — capture one field of a 10MB object and you keep the field, capture the object and you keep all 10MB. Engine & memory has the heap snapshots that let you see which.
Seeing one for yourself
This is not a metaphor you have to take on faith. Put a
debugger statement inside a returned function, run it,
and open the Scope panel in DevTools: alongside
Local and Global there will be an entry
labelled Closure (makeCounter), with the retained
variables listed inside it — the middle plane of the 3D scene, printed
as a tree.
The traps, collected
- Expecting a snapshot. The closure reads the variable when it runs, not when it was created.
- Sharing an environment by accident. Two functions from the same call share state — sometimes what you want, sometimes a bug.
-
Assuming a new closure per loop iteration. Only
letandconstgive you that. - Leaking through listeners and intervals. The closure cannot be collected while the subscription is alive.
- Thinking it is rare. Every callback, every event handler, every module is a closure. It is the default, not the exception.
Say it like this → "A function keeps a
link to the environment it was created in. When the function outlives
the call, that environment cannot be collected — so the variable is
still there, still live, and still shared with anything else created
in the same call."
Interview answer, one sentence
"A closure is a function plus a live link to the scope where it was created, so it keeps those variables alive after the outer function returns; it captures the variable, not a copy of its value, which is why counters and private state work and why a var in a loop gives every callback the same value."
Before you move on
- Explain why
countinmakeCountersurvives after the function returns, in terms of what the garbage collector can and can't touch. - Say why two calls to the same factory function produce two independent closures, not one shared one.
- Build a private counter or cache using a closure, with no property anyone outside can reach directly.
- Explain why the
varloop prints3 3 3and theletloop prints0 1 2, in terms of how many bindings each one creates. - Name two ways a closure can leak memory in production, and the fix for each.
Opens in the editor: write it, run it, and check it against real tests.
All 35 for this level →Finished reading?
0 of 41 chapters read in this series