Understanding Closures in JavaScript with Real Examples
I explain what a JavaScript closure really captures, why returned functions still reach outer variables, and the loop bug and memory cost that catch people out.

Here’s a belief worth testing: when a function returns, its local variables are gone. The call ended, the stack frame came off, the workspace got cleaned up. Most languages work exactly that way. Then you run this:
function makeGreeter() {
const name = "Ada";
return function greet() {
console.log(`hi ${name}`);
};
}
const greet = makeGreeter();
greet(); // "hi Ada"
makeGreeter finished before greet ever ran. By the simple belief, name should be long gone. It isn’t — greet still finds it, reads it, and uses it. No parameter passing, no global variable, no trick. Just a closure.
This is the post where I unpack what actually got captured, using the execution-context model from JavaScript Execution Contexts Explained as the foundation. By the end you’ll be able to predict closure behaviour in real code — including the classic loop bug and the memory cost nobody warns you about.
Series: JavaScript deep-dive, part 2. Start with JavaScript Execution Contexts Explained — this post builds directly on its scope-chain model.
On this page
- What a closure actually is
- A counter that shouldn’t work
- Closures capture variables, not values
- The loop bug that made everyone switch to let
- What closures are actually for
- The cost: closures keep environments alive
- A function remembers where it was born
What a closure actually is
The definition is shorter than most explanations make it sound. MDN’s guide calls a closure “the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment)”. Two parts: the function, and the bag of variables it can see around it.
Where the “bag” comes from is the part the previous post set up. Every function is created inside some context, and scopes nest lexically — by where the code sits in the file, not by what calls it. When the engine creates a function, it stores a reference to that surrounding environment on the function itself. The spec is explicit about this: every function object carries an internal [[Environment]] slot — “the Environment Record that the function was closed over. Used as the outer environment when evaluating the code of the function”.
That slot is the closure, mechanically speaking. The outer function’s context can pop off the call stack; the environment — the record holding its variables — survives, because the inner function still points at it. When you call the inner function later, wherever, its identifier lookups walk outward through that preserved chain.
One consequence worth slowing down for: closures are created every time a function is created — not just in fancy patterns with returned functions. Every function you’ve ever written is a closure. Most of them just never outlive their surroundings, so you never notice.
A counter that shouldn’t work
Time to make the definition do something. Here’s the canonical working example:
function makeCounter() {
let count = 0;
return function next() {
count += 1;
return count;
};
}
const ticketLine = makeCounter();
const queueLine = makeCounter();
ticketLine(); // 1
ticketLine(); // 2
queueLine(); // 1 — a completely separate counter
Two details matter more than the first 1.
First, next doesn’t just read count — it writes to it, and the write sticks between calls. The closure holds the variable itself, not a copy of its value at some moment.
Second, ticketLine and queueLine count independently. Each call to makeCounter created a fresh environment with its own count, and each returned function carries its own [[Environment]] reference. Two functions from the same source code, two separate memories.
Notice what you can’t do: there is no ticketLine.count. The variable exists — the functions prove it — but nothing outside can reach it. You’ve just built private state, without classes, without anything. Hold that thought; it earns a section later.
Closures capture variables, not values
People often say a closure “remembers the value” of an outer variable. Close, but the distinction matters, and this pair of examples shows why:
function makeWatcher() {
let value = 1;
return {
read: () => value,
write: (v) => {
value = v;
},
};
}
const w = makeWatcher();
w.read(); // 1
w.write(99);
w.read(); // 99
If the first read() had captured a value, the second would still print 1. It prints 99, because the closure captured the binding — the variable slot itself. Both read and write reach into the same surviving environment, so writes through one function are visible to the other.
The model I’d offer: a closure is a live phone line to a specific variable, not a photograph of it. Phone lines mean the variable can change under you — which is exactly what the next section is about.
The loop bug that made everyone switch to let
This is the bug that sold a generation of JavaScript developers on let. Predict the output before reading on:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 10);
}
The callbacks run later, after the loop is done. By then, the shared output is 3, three times — not 0, 1, 2. Swap one word:
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 10);
}
// 0, 1, 2
| Loop variable | What each callback closes over | Output |
|---|---|---|
var i |
one shared binding, function-scoped | 3, 3, 3 |
let j |
a fresh binding per iteration | 0, 1, 2 |
The var version isn’t a timing bug or a quirk of timers. All three arrow functions close over the same i binding, because var creates exactly one function-scoped variable for the whole loop — as MDN’s guide explains with its field-help example, every callback shares the same lexical environment and reads the variable when it eventually runs, after the loop has moved that one variable to its final value.
The let version works because let is block-scoped: each pass through the loop body is its own block, so each iteration gets a new j, and each closure latches onto its own. Same code shape, completely different binding structure underneath.
If you’re attaching event handlers to elements in the DOM — a pattern that fits right into the page lifecycle from parsing to user interaction — this is the same trap with different clothes: handlers fire long after the loop that attached them, so whatever they closed over had better be per-iteration.
Try this
- Open your browser console and paste the
varloop above.- Predict the output before the timers fire.
- Change
var itolet iand run it again.Expected result: the
varversion prints3, 3, 3; theletversion prints0, 1, 2. For a harder round, trysetTimeout(() => console.log(i++), 10)withvar: the callbacks now share and mutate the binding, so the timers fire in order and print3, 4, 5.
What closures are actually for
Once the mechanism is clear, the practical uses stop looking like interview trivia.
Private state. The counter example is the honest version of a pattern that powered an entire era of JavaScript: wrap state in a function, expose only the functions that should touch it. Before modules existed, developers wrapped whole libraries in an immediately invoked function expression (IIFE) so the internals stayed unreachable from outside. The pattern still earns its keep in ordinary objects:
const createWallet = (openingBalance) => {
let balance = openingBalance;
return {
deposit: (amount) => {
balance += amount;
},
getBalance: () => balance,
};
};
const wallet = createWallet(100);
wallet.deposit(50);
wallet.getBalance(); // 150
There’s no wallet.balance to read and no assignment that reaches the variable — balance is a closed-over binding, not an object property. Overwriting wallet.getBalance at worst breaks the method; the amount inside stays untouchable. Classes now have their own private fields with the # syntax, which covers much of this ground in class-shaped code — but closures remain the tool when you don’t want a class.
Caching helpers. A memoize wrapper hides its cache where no caller can reach it, not even by accident:
function memoize(fn) {
const cache = new Map();
return (n) => {
if (cache.has(n)) return cache.get(n);
const result = fn(n);
cache.set(n, result);
return result;
};
}
The cache is a closed-over binding, so every call through the returned function checks and fills the same store — and no other code can clear or corrupt it. You get a private, per-wrapper cache with no class and no module-global in sight.
Callbacks that remember context. Every event handler, timer callback, and promise callback that touches an outer variable is a closure doing its job. The pattern is invisible when it works and mysterious when it leaks, which is the last stop of this tour.
Hooks-style APIs. The same mechanism explains why component hooks feel magical. In React, a component is a function that runs once per render, and every handler it defines closes over that render’s local values. The React docs state it plainly: “A state variable’s value never changes within a render” — so a handler created in an earlier render still sees that render’s value when it finally runs, even after state has moved on. That’s the loop bug again, formalised into an API, and it’s why the docs point you to updater functions when you need the latest value instead of the captured one.
Function factories. makeAdder-style factories — return a function pre-loaded with part of its configuration — are closures used as lightweight specialisation. Same mechanism, different hat.
The cost: closures keep environments alive
Here’s the trade-off. A closure’s value comes from keeping an environment alive after its context is gone — and that is also its cost. Modern engines use mark-and-sweep garbage collection, where memory is freed only when it becomes explicitly unreachable from the roots. So as long as the inner function is reachable, the environment it points at — and, transitively, everything that environment references — cannot be collected. A long-lived callback that closes over one small counter is fine. A long-lived callback that closes over a function that holds a huge array keeps the array alive too, because the captured environment keeps it reachable. Engines can sometimes optimise bindings you never read, but I wouldn’t lean on that — when a heap snapshot shows a large object whose retained size refuses to drop, a closure’s preserved environment is a likely suspect.
MDN’s guide adds a speed dimension: creating functions inside functions “when closures are not needed” costs processing and memory, because every function instance manages its own scope. The classic example is defining methods inside a constructor — every object built gets brand-new closures per method, where a prototype (or modern class) method would be created once.
Neither cost is a reason to avoid closures; they’re the reason to notice what you’re closing over. The fix is usually structural: narrow the scope, close over the small thing, not the big container it arrived in. When you suspect a closure is pinning something large, DevTools heap snapshots can confirm it — the memory terminology guide explains how the retained-size column shows exactly what would be freed if a given object became unreachable.
A function remembers where it was born
The shift worth keeping: a JavaScript function is not just reusable code. It’s code plus a place — the environment it was created in, carried along for its whole life. Locals “dying” with the call was never quite true; they die when nothing remembers them, and a closure is precisely a function that remembers.
When code surprises you next — a callback that sees the wrong value, a counter nobody can reset, a variable that refuses to be garbage — ask the closure question: which environment was this function born in, and who else holds a reference to it? The next post in this series follows functions that are born in one moment and called in another at scale: asynchronous JavaScript, promises, and the event loop.
Sources
- Closures — MDN Web Docs JavaScript Guide
- ECMAScript Function Objects, internal slots including [[Environment]] — ECMAScript Specification, clause 10.2
- Memory management, mark-and-sweep and reachability — MDN Web Docs
- State as a Snapshot — React documentation
- Memory terminology, shallow and retained size — Chrome DevTools
- IIFE — MDN Web Docs Glossary
- Private properties (“hash” fields) — MDN Web Docs