Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 08 / 18 30 MIN

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.

Lab 30 minutes

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.