Functions, closures and modules
Closures and Private State in JavaScript
A closure is a function plus the scope it captured. That mechanism hides state, keeps it alive, and explains the module pattern and #private fields.
A closure is not an advanced feature you add to a program. It is what happens whenever a function is created inside another function, and it is already happening in code you wrote last week. The moment an inner function mentions a name from its enclosing scope, the function and that scope travel together for as long as the function exists.
The same mechanism gives you two things that look unrelated. It is how you get state that the outside world cannot reach, which is how the module pattern worked for a decade before classes had private fields. And it is how a single event listener can keep a large object alive months after the code that created both was finished with it. Learn the mechanism once and both of those stop being surprises.
A closure is a function plus the bindings it captured
The definition worth keeping is literal. When a function is created, it records a reference to the environment it was created in. A closure is that function together with that recorded environment. The environment is not a copy of the values: it is the live set of bindings, so two functions created in the same scope share them and see each other's changes.
function makeCounter(start = 0) {
let count = start;
return {
increment() { count += 1; return count; },
value() { return count; },
};
}
const a = makeCounter();
const b = makeCounter(100);
a.increment();
a.increment();
b.increment();
console.log("a:", a.value(), "b:", b.value()); // a: 2 b: 101
console.log("outside:", typeof count); // outside: undefined
Each call to makeCounter creates a new environment, so a and
b have separate count bindings and neither can see the other's. The
typeof count at the end is undefined rather than an error because
the module scope has no binding by that name at all. Nothing outside can read or write
count except through the two functions that were handed out, which is the
property that makes this useful.
Two consequences follow from the definition. Because the environment survives as long as
the function does, a closure can outlive the call that created it; that is why
a.increment() still works after makeCounter has returned. And
because the environment is shared rather than copied, every function created in one call sees
the same mutable state, which is either the point or the bug depending on what you intended.
If you want two functions to coordinate, make them in the same call. If you want isolation,
make separate calls.
The module pattern hid state before classes had private fields
Before #private existed, the only way to get state that no outside code could
touch was to create it in a scope and return only the functions that were allowed to use it.
An immediately invoked function expression was the standard container, because it created the
scope and ran exactly once:
const store = (function () {
const items = new Map();
let nextId = 1;
function add(name) {
const id = nextId;
nextId += 1;
items.set(id, name);
return id;
}
return {
add,
get: (id) => items.get(id),
get size() { return items.size; },
};
})();
const id = store.add("widget");
console.log(id, store.get(id), store.size); // 1 widget 1
console.log(typeof items, Object.keys(store)); // undefined [ 'add', 'get', 'size' ]
The returned object is the public surface and nothing else. items is not a
property of it, so store.items is undefined and there is no key to
enumerate. The IIFE is not strictly necessary any more: a module has its own scope, and a
plain let at the top of a module file is equally unreachable from an importer,
which is why top-level module state replaced a lot of the old wrapper functions.
The pattern has a real cost, and it is worth being honest about. Every instance created
through a factory carries its own copy of every method, because each call creates new
function objects. A class puts methods on a shared prototype, so fifty thousand instances
share one increment function:
class WithFields {
#n = 0;
inc() { this.#n += 1; return this.#n; }
}
const a = new WithFields();
const b = new WithFields();
console.log(a.inc === b.inc); // true, one shared function
console.log(Object.getOwnPropertyNames(WithFields.prototype)); // [ 'constructor', 'inc' ]
The class version also keeps real state on the instance rather than in a captured environment, which is easier to debug and easier for the engine to optimise. I reach for a factory with closures when I want a single instance with a small surface, and for a class when there will be many instances or when the state has a name worth showing in a debugger.
Private class fields do the same job with syntax instead of scope
A field whose name begins with # is private to the class body. It is not a
property: it does not appear in Object.keys, in JSON.stringify, or
in a spread, and the name cannot be reached from outside the class at all.
class Temperature {
#celsius;
constructor(celsius) { this.#celsius = celsius; }
get fahrenheit() { return this.#celsius * 9 / 5 + 32; }
toString() { return `${this.#celsius}C`; }
}
const t = new Temperature(20);
console.log(t.fahrenheit, String(t)); // 68 20C
console.log(Object.keys(t), JSON.stringify(t)); // [] {}
console.log("#celsius" in t, t["#celsius"]); // false undefined
That last line is the part people expect to be a runtime error and is not. A private name
is not a string key that happens to be hidden; it is a separate namespace that only the class
body can name. Writing t.#celsius outside the class is a syntax error the parser
rejects, and the string-key forms above are ordinary property lookups that find nothing.
There is no reflection API that reads it either, which is the security property closures
cannot offer: a captured variable is unreachable by convention, while a private field is
unreachable by construction.
The field is still per-instance state, so the method that reads it has all the usual receiver problems. Detaching a method from its instance breaks it, in this case with a private field error rather than a plain property error:
class Cart {
#items = ["book"];
summary() { return this.#items.join(", "); }
}
const cart = new Cart();
const detach = cart.summary;
detach(); // TypeError: Cannot read properties of undefined (reading '#items')
cart.summary.bind(cart)(); // book
Subclasses get their own private fields, and a parent's field is not visible to a child even when the names match. The two fields below are distinct bindings, which is the behaviour you want and a difference from the closure version, where a naming collision would be a genuine shadowing bug:
class Base {
#secret = "base";
reveal() { return this.#secret; }
}
class Sub extends Base {
#secret = "sub";
own() { return this.#secret; }
}
const s = new Sub();
console.log(s.reveal(), s.own()); // base sub
Before private fields, the well-known workaround was a WeakMap keyed by the
instance, with the state as the value. It works, and its privacy is stronger than a closure
because the state lives outside the object entirely:
const state = new WeakMap();
class Counter {
constructor() { state.set(this, { count: 0 }); }
increment() {
const s = state.get(this);
s.count += 1;
return s.count;
}
}
const c = new Counter();
console.log(c.increment(), Object.keys(c), JSON.stringify(c)); // 1 [] {}
I would not write that today. It costs a map lookup per access, it makes the state invisible
to the debugger, and the entry disappearing when the instance is collected is a subtlety you
have to trust rather than see. Understand the pattern because you will meet it in code written
before class fields were available, then write # instead.
A closure keeps what it references alive, and that is the whole memory story
The environment a closure captured stays reachable while the closure is. That is the same
sentence as the definition, but the consequence is easier to see with a
FinalizationRegistry, which reports when an object is actually collected:
const registry = new FinalizationRegistry((label) => console.log("collected:", label));
function makeReader() {
const rows = new Array(5000).fill("row");
const read = () => rows;
registry.register(rows, "rows captured by the closure");
return read;
}
let read = makeReader();
console.log("read():", read().length); // read(): 5000
globalThis.gc();
setTimeout(() => {
globalThis.gc();
console.log("still callable while held:", read().length);
read = null;
globalThis.gc();
setTimeout(() => globalThis.gc(), 30);
}, 30);
read(): 5000
still callable while held: 5000
collected: rows captured by the closure
Run that with node --expose-gc. While read is reachable the array
survives every collection, and the report arrives only after the closure itself is dropped. A
finalizer is not a synchronous event either, so do not write a test that depends on when that
last line appears, only on whether it appears. That is the rule, and it explains the leak shape
you will actually meet in production: an event listener registered on a long-lived element or
bus, closed over a payload that should have been free when the component unmounted, never
removed.
const bus = [];
const element = {
on(name, fn) { bus.push({ name, fn }); },
off(name, fn) {
const i = bus.findIndex((h) => h.fn === fn);
if (i >= 0) bus.splice(i, 1);
},
};
function attach(el) {
const payload = new Uint8Array(1024);
function handler() { return payload.length; }
el.on("click", handler);
return () => el.off("click", handler);
}
const detach = attach(element);
console.log("listeners after attach:", bus.length); // 1
detach();
console.log("listeners after detach:", bus.length); // 0
Nothing here is exotic. The payload is reachable for exactly as long as the handler is
registered, and the handler is reachable because the bus holds a reference to it. Removing the
listener removes the reference, the handler is collectable, and so is the payload. Every
framework subscription API returns some version of that detach function for this
reason, and a component that ignores the returned unsubscribe leaks its whole closure.
One caveat about the mechanism, because it is often stated too strongly in both directions. The specification says the closure references the environment record; it does not promise that every binding in that record is retained, and engines are free to keep only what is referenced. My own measurements on Node 24 agree with the optimised behaviour rather than the pessimistic story, so treat "a closure retains the whole enclosing scope" as a rule of thumb for reasoning, not as a guarantee you can measure. What you can rely on is the consequence: the closure itself keeps running and its referenced bindings keep their values until the closure is dropped.
Closures in loops capture bindings, so the declaration decides the result
This is the closure bug everyone meets, and it is entirely a consequence of the definition.
A closure captures the binding, not the value the binding held at the time. With
var, the loop has a single function-scoped binding, so every closure made in the
loop sees the final value:
const withLet = [];
for (let i = 0; i < 3; i += 1) withLet.push(() => i);
const withVar = [];
for (var j = 0; j < 3; j += 1) withVar.push(() => j);
console.log("let loop:", withLet.map((f) => f())); // let loop: [ 0, 1, 2 ]
console.log("var loop:", withVar.map((f) => f())); // var loop: [ 3, 3, 3 ]
The var version is not off by one. All three closures captured one binding, the
loop finished with j equal to 3, and the closures report what the
binding holds when they run. let changes the result because the loop creates a
fresh binding per iteration, copying the previous value before the update step. If that
copying is easier to picture than the specification language, write it out by hand and the
behaviour is identical:
const explicit = [];
for (let i = 0; i < 3; i += 1) {
const captured = i;
explicit.push(() => captured);
}
console.log(explicit.map((f) => f())); // [ 0, 1, 2 ]
Two related traps are worth knowing because they look like the same bug and are not. A
var declared in the loop body rather than used as the loop variable has the same
single-binding problem, and moving the loop variable to let does not fix it;
const for the per-iteration value does. And a factory function called once per
iteration is not a closure bug at all, because the parameter is a new binding in each call:
const maker = (n) => () => n;
console.log([0, 1, 2].map(maker).map((f) => f())); // [ 0, 1, 2 ]
When a loop and a function meet, ask which binding the function can see and how many of those bindings exist. The answer is always the answer.
Write two versions of a rate limiter that allows at most three calls per key and
refuses the fourth. Both versions must expose allow(key) and
remaining(key) and must not expose the counters as properties.
Version one uses a factory and closure state. Version two uses a class with a private field. Both take the limit as a constructor or factory argument.
Then answer: which version creates fewer function objects when you make ten thousand
limiters, and what does each version do if a caller keeps a reference to
allow after the limiter itself is dropped from your data structure?
Show the solution and the reasoning
The closure version keeps the counters in a Map in the factory's scope. The
only way in is through the returned functions.
function createLimiter(limit) {
const used = new Map();
function allow(key) {
const count = used.get(key) ?? 0;
if (count >= limit) return false;
used.set(key, count + 1);
return true;
}
return {
allow,
remaining: (key) => Math.max(0, limit - (used.get(key) ?? 0)),
};
}
The class version puts the counters on the instance as a private field, so access goes
through this and the receiver matters.
class RateLimiter {
#used = new Map();
#limit;
constructor(limit) { this.#limit = limit; }
allow(key) {
const count = this.#used.get(key) ?? 0;
if (count >= this.#limit) return false;
this.#used.set(key, count + 1);
return true;
}
remaining(key) {
return Math.max(0, this.#limit - (this.#used.get(key) ?? 0));
}
}
On function objects, the class wins and the margin is not close. Each
createLimiter call allocates a new allow closure and a new
remaining closure, so ten thousand limiters allocate twenty thousand functions.
The class allocates two functions total, on RateLimiter.prototype, and each
instance allocates a Map and a number. If the limiters are long-lived and
numerous, that is the deciding argument. If there is exactly one limiter for the process
lifetime, it does not matter and the factory is shorter to read.
On the dropped-reference question, the two versions differ in a way worth understanding
rather than memorising. In the closure version, a saved allow function holds the
captured scope, so the whole used map stays alive even after every other
reference to the limiter object is gone. Your data structure forgot about the limiter; the
closure did not. In the class version, the saved method holds no state at all, only a
reference to RateLimiter.prototype, so it keeps the prototype alive and nothing
else. The per-instance state dies with the instance, and calling the saved method later
throws a private field error because this is undefined. The first
behaviour is quiet retention and the second is a loud failure, and for a rate limiter I
prefer the loud one.
Two design points the code does not show. Neither version is safe under concurrency in
the JavaScript sense, because there is no concurrency: allow runs to completion
before anything else runs, so read-modify-write on the Map cannot interleave.
And neither version expires keys, so a limiter that sees a million distinct keys keeps a
million entries. Add a timestamp and a sweep, or use a store that expires entries, before
this goes near a production request path.
Progress
Mark this lesson done and it stays marked in this browser.