LEVEL 2 · LESSON 14 OF 19

Scope, closures and recursion Foundation

Closures in depth

See how closures really work through lexical environments, then use them for private state, function factories, memoization and cleanup, without leaking memory.

  • 45 min to read and try
  • You need: Scope and how code runs, Functions, and Objects in depth
  • You build: A per-account daily transfer limit with a fake clock for testing, plus a memoized fee calculator with a size limit
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Draw the lexical environments behind any closure and predict what it sees
  • Keep state private with closures and choose between a closure and a class
  • Explain and fix the loop closure bug in all its forms
  • Write function factories such as once and a configured fee calculator
  • Memoize a pure function with a bounded cache
  • Spot what a closure keeps alive and avoid the listener leak

Two customers, one daily limit

A savings app lets each customer transfer at most ₦50,000 per day. The first version keeps the running total in a variable at the top of the module, the simplest place a beginner can think of:

problem.js
const DAILY_LIMIT_KOBO = 5000000;
let sentTodayKobo = 0;

function authorizeTransfer(customer, amountKobo) {
  if (sentTodayKobo + amountKobo > DAILY_LIMIT_KOBO) {
    return `${customer}: refused`;
  }
  sentTodayKobo += amountKobo;
  return `${customer}: sent ₦${amountKobo / 100}`;
}

console.log(authorizeTransfer("Ada", 3000000));
console.log(authorizeTransfer("Chidi", 3000000));

sentTodayKobo = 0;
console.log(authorizeTransfer("Ada", 3000000));
Output of node problem.js and of the browser terminal
Ada: sent ₦30000
Chidi: refused
Ada: sent ₦30000

Two bugs sit in these few lines:

  1. Chidi was refused because of Ada's transfer. There is one sentTodayKobo for the whole program, but the rule is per customer.
  2. Any code in the module can write sentTodayKobo = 0 and wipe the limit. Here it is one obvious line; in a real codebase it is one line in a file you never read.

What you need is state that belongs to one customer, and that only the limit logic can change. You already met the tool in Scope and how code runs: a closure, a function together with the variables it remembers from where it was created. That lesson showed what a closure does. This one shows how it works, so you can predict every case, and then uses it for the jobs closures do in real backends: private state, configured functions, caching, and cleanup. At the end you fix this limiter properly and test it.

Lexical environments: the model behind closures

To predict what a closure sees, you need one mental model. JavaScript keeps variables in lexical environments (the specification's name; you can think "scope objects"). An environment is a table of names and their current values, plus a link to the environment around it, called its outer environment.

  • When a module starts, it gets a module environment. Its outer is the global environment (where console and Math live).
  • Every call of a function creates a new environment for that call's parameters and local variables. Blocks with let or const inside get one too.
  • When a function is created, it stores a hidden reference to the environment it was created in. The specification calls it [[Environment]].
  • When a function runs, its new call environment's outer link is set to that stored [[Environment]], not to the caller's. That is exactly why scope is lexical: it follows where code is written.

To look up a name, JavaScript checks the current environment, then its outer, then that one's outer, until it reaches the global environment. A closure is nothing more than a function whose stored environment is still needed after the call that created it has returned.

environments.js
function createAccount(owner) {
  let balanceKobo = 0;

  function deposit(amountKobo) {
    balanceKobo += amountKobo;
    return balanceKobo;
  }
  function balance() {
    return `${owner}: ₦${balanceKobo / 100}`;
  }
  return { deposit, balance };
}

const ada = createAccount("Ada");
const chidi = createAccount("Chidi");

ada.deposit(250000);
ada.deposit(100000);
chidi.deposit(5000);

console.log(ada.balance());
console.log(chidi.balance());
Output of node environments.js and of the browser terminal
Ada: ₦3500
Chidi: ₦50

Here is what exists in memory after those lines, drawn as boxes. Arrows are references:

 global environment        { console, Math, ... }
        ^ outer
 module environment        { createAccount, ada, chidi }
        ^ outer                          ^ outer
 call env #1                       call env #2
 { owner: "Ada",                   { owner: "Chidi",
   balanceKobo: 350000 }             balanceKobo: 5000 }
   ^            ^                    ^            ^
 ada.deposit  ada.balance       chidi.deposit  chidi.balance
 ([[Environment]] of each function points at the call that created it)

Three facts follow directly from the drawing, and they explain every closure puzzle you will meet:

  1. Each call makes a fresh environment. Ada and Chidi each have their own balanceKobo.
  2. Functions created in the same call share that call's environment. ada.deposit and ada.balance see the same balanceKobo, which is why the balance reflects both deposits.
  3. The environment holds variables, not snapshots. A closure reads the current value each time it runs.

Nested environments

Environments chain as deep as your functions nest. Each level can see everything outwards, and nothing inwards:

nested.js
const currency = "NGN";

function createShop(shopName) {
  let ordersPlaced = 0;

  return function createCheckout(customer) {
    let itemsInCart = 0;

    return function addItem(sku) {
      itemsInCart += 1;
      ordersPlaced += 1;
      return `${shopName}/${customer}: ${sku} (cart ${itemsInCart}, shop ${ordersPlaced}, ${currency})`;
    };
  };
}

const lagosShop = createShop("Lagos");
const adaAdds = lagosShop("Ada");
const tundeAdds = lagosShop("Tunde");

console.log(adaAdds("RICE-5"));
console.log(adaAdds("OIL-1"));
console.log(tundeAdds("SALT"));
Output of node nested.js and of the browser terminal
Lagos/Ada: RICE-5 (cart 1, shop 1, NGN)
Lagos/Ada: OIL-1 (cart 2, shop 2, NGN)
Lagos/Tunde: SALT (cart 1, shop 3, NGN)

itemsInCart lives in each createCheckout call, so Ada and Tunde have separate carts. ordersPlaced lives one level out, in the single createShop("Lagos") call, so both checkouts share and increase it. currency is in the module environment and everyone sees it. Where a variable is declared decides who shares it.

Variables, not values

Scope and how code runs showed that a closure sees later changes to a variable. The flip side catches people all the time: when you want a snapshot, you must make one. Copy the value into a variable that will not change, or pass it as an argument, which creates a new variable in the new call:

snapshot.js
let exchangeRate = 1500;

const liveQuote = (usd) => `₦${usd * exchangeRate}`;

function quoteWithRate(rate) {
  return (usd) => `₦${usd * rate}`;
}
const lockedQuote = quoteWithRate(exchangeRate);

console.log(liveQuote(10), lockedQuote(10));
exchangeRate = 1650;
console.log(liveQuote(10), lockedQuote(10));
Output of node snapshot.js and of the browser terminal
₦15000 ₦15000
₦16500 ₦15000

liveQuote reads exchangeRate from the module environment every time, so it follows the new rate. lockedQuote reads rate, a parameter of the call quoteWithRate(1500). Nothing ever assigns to that parameter again, so it keeps 1500 forever. A shop that shows a customer a price and then charges them at checkout needs exactly this: a quote locked at the rate they saw.

Private state

The first fix for the limiter is to move the total into a function call, so each customer gets one, and nothing outside can reach it. Compare three ways to hold a balance:

private.js
const plain = { balanceKobo: 0, deposit(amountKobo) { this.balanceKobo += amountKobo; } };
plain.deposit(1000);
plain.balanceKobo = 99999999;
console.log("plain object:", plain.balanceKobo);

function createAccount() {
  let balanceKobo = 0;
  return Object.freeze({
    deposit(amountKobo) {
      if (!Number.isInteger(amountKobo) || amountKobo <= 0) throw new RangeError("bad amount");
      balanceKobo += amountKobo;
    },
    balance: () => balanceKobo,
  });
}

const account = createAccount();
account.deposit(1000);
try {
  account.balanceKobo = 99999999;
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}
try {
  account.balance = () => 99999999;
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}
console.log("closure:", account.balance());

class Account {
  #balanceKobo = 0;
  deposit(amountKobo) {
    this.#balanceKobo += amountKobo;
  }
  balance() {
    return this.#balanceKobo;
  }
}
const classAccount = new Account();
classAccount.deposit(1000);
console.log("class:", classAccount.balance());
Output of node private.js and of the browser terminal
plain object: 99999999
TypeError: Cannot add property balanceKobo, object is not extensible
TypeError: Cannot assign to read only property 'balance' of object '#<Object>'
closure: 1000
class: 1000

The plain object trusts every caller. The closure version keeps balanceKobo in the call environment, where no outside code can name it. Freezing the returned object (see Objects in depth) also stops someone from replacing balance with a fake function. The class version uses a #private field, from this, prototypes and classes, and reaches the same goal.

Closure or class?

Closure (factory function)Class with #private fields
PrivacyComplete: the variables have no name outsideComplete: #field is a syntax error outside the class
thisNot used, so methods can be passed as callbacks safelyMethods lose this when passed as callbacks (see this in depth)
Memory per objectEvery object gets its own copy of every method functionMethods live once on the prototype and are shared
instanceof, inheritanceNoYes

Both are good. A closure fits a handful of long-lived objects (a limiter per customer, a service, a store) and callback-heavy code. A class fits large numbers of objects of one kind, such as a million order lines, where sharing methods saves memory. ZudoJS uses closures of exactly this shape: createRateLimiter from @zudojs/security keeps its request counts in a Map inside the factory call and returns a function that checks them.

Closures in loops

The loop bug from Scope and how code runs makes complete sense with environments. Here it is without timers: each loop turn stores a function that will send a reminder later.

loops.js
const customers = ["Ada", "Chidi", "Tunde"];

const withVar = [];
for (var i = 0; i < customers.length; i++) {
  withVar.push(() => `remind ${customers[i]} (#${i})`);
}
console.log(withVar.map((send) => send()));

const withLet = [];
for (let j = 0; j < customers.length; j++) {
  withLet.push(() => `remind ${customers[j]} (#${j})`);
}
console.log(withLet.map((send) => send()));
Output of node loops.js and of the browser terminal
[
  'remind undefined (#3)',
  'remind undefined (#3)',
  'remind undefined (#3)'
]
[ 'remind Ada (#0)', 'remind Chidi (#1)', 'remind Tunde (#2)' ]
  • var i lives in the environment of the whole module (or function), and there is only one. All three arrows point at that one environment, and when they run, i is 3. customers[3] is undefined: the bug is not just a wrong number, it reads past the end of the array.
  • let j in a for head gets special treatment: JavaScript creates a new environment for every turn of the loop and copies the current value of j into it before the turn starts. Each arrow captures its own turn's environment.

let alone is not the fix

The per-turn copy only happens for a let declared in the for head. A let declared outside the loop is one variable, and you get the old bug back:

while-loop.js
const customers = ["Ada", "Chidi", "Tunde"];
const reminders = [];

let index = 0;
while (index < customers.length) {
  reminders.push(() => `remind #${index}`);
  index += 1;
}
console.log(reminders.map((send) => send()).join(", "));

const fixed = [];
let n = 0;
while (n < customers.length) {
  const position = n;
  fixed.push(() => `remind #${position}`);
  n += 1;
}
console.log(fixed.map((send) => send()).join(", "));

const best = customers.map((name, position) => () => `remind ${name} (#${position})`);
console.log(best.map((send) => send()).join(", "));
Output of node while-loop.js and of the browser terminal
remind #3, remind #3, remind #3
remind #0, remind #1, remind #2
remind Ada (#0), remind Chidi (#1), remind Tunde (#2)

The rule that always works: capture a variable that never changes after the closure is made. A const inside the loop body is a new variable each turn (the block gets a new environment each time). Array methods such as map and forEach are even simpler: every item is a separate call, so every callback has its own parameters.

The IIFE in old code

Before let existed, the only way to get a new environment was to call a function. You will see this pattern in older code, called an IIFE (Immediately Invoked Function Expression): a function written and called on the spot.

iife.js
var reminders = [];
for (var i = 0; i < 3; i++) {
  (function (position) {
    reminders.push(function () {
      return "remind #" + position;
    });
  })(i);
}
console.log(reminders.map(function (send) { return send(); }).join(", "));
Output of node iife.js and of the browser terminal
remind #0, remind #1, remind #2

Each turn calls the anonymous function with the current i, and the call's parameter position lives in a fresh environment. It works, but in new code, let, const and array methods do the same job more clearly.

Function factories

A function factory is a function that builds and returns another function, configured by the arguments you gave the factory. The returned function remembers the configuration through its closure. You met a tiny one in Functions (multiplier). Real ones read configuration once and then run many times:

fee-factory.js
function createFeeCalculator({ percent, minKobo, capKobo }) {
  if (percent < 0 || minKobo > capKobo) throw new RangeError("invalid fee settings");
  const rate = percent / 100;

  return function feeFor(amountKobo) {
    const raw = Math.round(amountKobo * rate);
    return Math.min(Math.max(raw, minKobo), capKobo);
  };
}

const transferFee = createFeeCalculator({ percent: 0.5, minKobo: 1000, capKobo: 200000 });
const cardFee = createFeeCalculator({ percent: 1.5, minKobo: 0, capKobo: 500000 });

for (const amountKobo of [50000, 5000000, 90000000]) {
  console.log(amountKobo, transferFee(amountKobo), cardFee(amountKobo));
}
Output of node fee-factory.js and of the browser terminal
50000 1000 750
5000000 25000 75000
90000000 200000 500000

The settings are checked once, in the factory, and the rate is computed once. Each fee function then only does the work that depends on the amount. The rest of the program only sees transferFee(amount) and never has to pass the settings around again. This is called partial application: supplying some of the inputs now and the rest later. Functional JavaScript takes it further.

Wrapping a function: once

A factory can also take a function and return an improved version of it. A classic example protects against a double click on "Pay": the payment must run once, however many times the handler is called:

once.js
function once(fn) {
  let called = false;
  let result;
  return (...args) => {
    if (!called) {
      called = true;
      result = fn(...args);
    }
    return result;
  };
}

let charges = 0;
const payOrder = once((orderId, amountKobo) => {
  charges += 1;
  return `charged ₦${amountKobo / 100} for ${orderId}`;
});

console.log(payOrder("ORD-7", 1170000));
console.log(payOrder("ORD-7", 1170000));
console.log(payOrder("ORD-8", 500));
console.log("charges:", charges);
Output of node once.js and of the browser terminal
charged ₦11700 for ORD-7
charged ₦11700 for ORD-7
charged ₦11700 for ORD-7
charges: 1

called and result are private to this one wrapper. Note what the third line shows: a different order also got the first result, because once ignores its arguments after the first call. That is correct for "initialise the database connection once", and dangerous for payments in general. For real payments, protection against duplicates is keyed by the order id and stored in the database, a topic called idempotency, which you will meet in the backend lessons.

Memoization

Memoization means remembering the results of a function so that a repeated call with the same arguments returns the stored result instead of doing the work again. The memory lives in a closure. Here the "expensive" work is a delivery quote, and a counter shows how often it really runs:

memoize.js
function memoize(fn) {
  const cache = new Map();
  return (key) => {
    if (cache.has(key)) return cache.get(key);
    const result = fn(key);
    cache.set(key, result);
    return result;
  };
}

const ZONES = Object.freeze({ Lagos: 150000, Abuja: 250000, Kano: 300000 });
let computed = 0;

function deliveryQuoteKobo(city) {
  computed += 1;
  return ZONES[city] + city.length * 1000;
}

const quote = memoize(deliveryQuoteKobo);
const orders = ["Lagos", "Abuja", "Lagos", "Lagos", "Kano", "Abuja"];

console.log(orders.map(quote));
console.log("computed:", computed);
Output of node memoize.js and of the browser terminal
[ 155000, 255000, 155000, 155000, 304000, 255000 ]
computed: 3

In a real shop the quote would come from slow work, such as a route calculation; the counter stands in for that cost. Six quotes, three computations: one per distinct city. A Map is the right cache, because its keys can be any value and it has no inherited names (see Objects in depth).

When memoization goes wrong

Memoization is only safe for pure functions, which give the same result for the same arguments and have no side effects (Functions). The cache also needs a correct key. Both conditions break easily:

memo-bugs.js
function memoize(fn) {
  const cache = new Map();
  return (key) => {
    if (!cache.has(key)) cache.set(key, fn(key));
    return cache.get(key);
  };
}

const stock = { "RICE-5": 10 };
const stockOf = memoize((sku) => stock[sku]);
console.log(stockOf("RICE-5"));
stock["RICE-5"] = 0;
console.log(stockOf("RICE-5"));

const totalOf = memoize((cart) => cart.items.reduce((sum, n) => sum + n, 0));
const cart = { items: [500, 700] };
console.log(totalOf(cart));
cart.items.push(300);
console.log(totalOf(cart));
console.log(totalOf({ items: [500, 700] }));
Output of node memo-bugs.js and of the browser terminal
10
10
1200
1200
1200
  • stockOf reads outside data that changes. After the rice sold out, the cache still says 10: the shop would sell stock it does not have. Never memoize a function that reads a database, the clock or any mutable state.
  • totalOf uses an object as the key, and Map compares keys by identity. The same cart after a change hits the stale entry (1200 instead of 1500), and every new cart object, even one with equal contents, misses the cache and adds another entry.

Several arguments and a size limit

For several primitive arguments, build a string key from them. And every cache in a long-running server needs a limit, or it grows until the process runs out of memory. A Map remembers insertion order, so the first key is the oldest entry; deleting it when the cache is full keeps the size bounded:

memo-limit.js
function memoize(fn, { maxSize = 100 } = {}) {
  const cache = new Map();
  const memoized = (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    if (cache.size > maxSize) cache.delete(cache.keys().next().value);
    return result;
  };
  memoized.size = () => cache.size;
  return memoized;
}

let calls = 0;
const route = memoize((from, to) => {
  calls += 1;
  return `${from} -> ${to}`;
}, { maxSize: 2 });

route("Lagos", "Abuja");
route("Lagos", "Kano");
route("Lagos", "Abuja");
route("Abuja", "Kano");
route("Lagos", "Abuja");
console.log("calls:", calls, "cached:", route.size());
Output of node memo-limit.js and of the browser terminal
calls: 4 cached: 2

The fourth call pushed the cache over its limit, so the oldest entry, Lagos to Abuja, was dropped, and the fifth call had to compute it again. Deleting the oldest entry is the simplest eviction rule; a better one moves an entry to the end each time it is used, which gives a least recently used (LRU) cache, built in the linked lists lesson. If the function throws, nothing is stored, so errors are not cached, which is usually what you want.

NOTE

JSON.stringify(args) is a fine key for strings, numbers and booleans. It is a poor key for objects (key order matters, and dates, maps and functions do not survive JSON), so memoize functions whose arguments are simple values, or pass an explicit key.

What a closure keeps alive

The garbage collector frees an object when nothing can reach it any more (Scope and how code runs). A closure is a way to reach things: as long as the function is reachable, the environment it points to is reachable, and so is everything in that environment that the closure uses. Usually that is exactly what you want. It becomes a memory leak when the function lives much longer than you expected.

The listener leak

The most common closure leak in servers: a long-lived object keeps a list of callbacks, code adds a callback for every request, and nobody ever removes them. Each callback keeps its request's data alive:

listeners.js
function createEventHub() {
  const listeners = new Set();
  return {
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    emit(event) {
      for (const listener of listeners) listener(event);
    },
    count: () => listeners.size,
  };
}

function handleRequestLeaky(hub, requestId) {
  const request = { id: requestId, body: "x".repeat(10000) };
  hub.subscribe((event) => `${request.id} saw ${event}`);
  return "done";
}

function handleRequest(hub, requestId) {
  const request = { id: requestId, body: "x".repeat(10000) };
  const unsubscribe = hub.subscribe((event) => `${request.id} saw ${event}`);
  try {
    return "done";
  } finally {
    unsubscribe();
  }
}

const leakyHub = createEventHub();
const cleanHub = createEventHub();
for (let i = 0; i < 1000; i++) {
  handleRequestLeaky(leakyHub, i);
  handleRequest(cleanHub, i);
}
console.log("listeners after leaky handlers:", leakyHub.count());
console.log("listeners after clean handlers:", cleanHub.count());
Output of node listeners.js and of the browser terminal
listeners after leaky handlers: 1000
listeners after clean handlers: 0

After 1,000 leaky requests, the hub holds 1,000 closures, and each one keeps its request, with a 10,000-character body, alive. A busy server handles millions of requests, so memory only grows until the process crashes. The fix is a closure too: subscribe returns an unsubscribe function that remembers exactly which listener to remove. Call it when the work is finished; a finally block (Handling errors) makes sure it runs even when the work throws. @zudojs/events uses the same pattern.

Keep only what you need

A closure keeps alive what it uses. If a long-lived callback only needs one field of a large object, copy that field into a local variable and use it instead of the whole object:

lean.js
function createReportLabel(report) {
  return () => `${report.title} (${report.rows.length} rows)`;
}

function createReportLabelLean(report) {
  const { title } = report;
  const rowCount = report.rows.length;
  return () => `${title} (${rowCount} rows)`;
}

const report = { title: "September sales", rows: Array.from({ length: 50000 }, (_, i) => ({ id: i })) };
const heavy = createReportLabel(report);
const lean = createReportLabelLean(report);
console.log(heavy());
console.log(lean());
Output of node lean.js and of the browser terminal
September sales (50000 rows)
September sales (50000 rows)

Both labels print the same text. But as long as heavy is reachable, the 50,000-row array is too; lean only keeps a short string and a number. One caution: engines keep one shared environment per call, so if any closure created in that call uses report, it stays alive for all of them. Keep long-lived closures in small functions that only hold what they need. Memory shows how to find leaks like these with heap snapshots.

Before you build: the per-customer limit

REASON IT OUT

Design the transfer limiter

You will now fix the limiter from the start of the lesson. Think these through before reading the code:

  • Where should each customer's running total live, so that customers are separate and outside code cannot reset it?
  • "Per day" needs the current date. If the code calls new Date() itself, how would you test what happens at midnight without waiting for midnight?
  • What amounts must be refused before they touch the total? What about an amount exactly equal to what is left?
  • If a transfer is refused, should the total change?
  • What can the caller be allowed to see?
Show the reasoning
  • Where the total lives: in the environment of a call to a factory, createTransferLimit(), one call per customer. Nothing outside can name the variable, so nothing can reset it.
  • Time: let the caller pass in a today function that returns the current day as a string. Production passes one that reads the real clock; tests pass a fake whose value they control. The limiter stores it in its closure and calls it on every check. Passing in a dependency like this is the same idea as dependency injection from this, prototypes and classes.
  • Amounts: only whole numbers of kobo above zero, so strings, NaN, negatives, zero and fractions are refused. Exactly the remaining amount is allowed (<=, not <): a boundary that deserves a test.
  • Refusals: a refused transfer must not change the total. Check first, then change the state, in that order.
  • Visibility: the caller may read how much is left today, but only through a function, never by writing a variable.

Build: a daily transfer limit

limit.js
export function createTransferLimit({ dailyLimitKobo, today }) {
  if (!Number.isInteger(dailyLimitKobo) || dailyLimitKobo <= 0) {
    throw new RangeError("dailyLimitKobo must be a positive whole number");
  }
  let day = today();
  let sentKobo = 0;

  function rollOver() {
    const current = today();
    if (current !== day) {
      day = current;
      sentKobo = 0;
    }
  }

  return Object.freeze({
    authorize(amountKobo) {
      if (!Number.isInteger(amountKobo) || amountKobo <= 0) {
        return { ok: false, reason: "invalid amount" };
      }
      rollOver();
      if (sentKobo + amountKobo > dailyLimitKobo) {
        return { ok: false, reason: "daily limit reached" };
      }
      sentKobo += amountKobo;
      return { ok: true };
    },
    remainingKobo() {
      rollOver();
      return dailyLimitKobo - sentKobo;
    },
  });
}

Everything a limiter needs, day, sentKobo, the settings and the clock, lives in the environment of one createTransferLimit call. The two methods and the helper rollOver share it. Now one limiter per customer, and a fake clock, which is just a closure over a variable the test controls:

main.js
import { createTransferLimit } from "./limit.js";

let fakeDay = "2026-09-24";
const today = () => fakeDay;

const limits = new Map();
function limitFor(customer) {
  if (!limits.has(customer)) {
    limits.set(customer, createTransferLimit({ dailyLimitKobo: 5000000, today }));
  }
  return limits.get(customer);
}

console.log("Ada", limitFor("Ada").authorize(3000000));
console.log("Chidi", limitFor("Chidi").authorize(3000000));
console.log("Ada", limitFor("Ada").authorize(3000000));
console.log("Ada left:", limitFor("Ada").remainingKobo());

fakeDay = "2026-09-25";
console.log("Ada next day", limitFor("Ada").authorize(3000000));
console.log("Ada left:", limitFor("Ada").remainingKobo());
Output of node main.js and of the browser terminal
Ada { ok: true }
Chidi { ok: true }
Ada { ok: false, reason: 'daily limit reached' }
Ada left: 2000000
Ada next day { ok: true }
Ada left: 2000000

Chidi is no longer refused because of Ada, and the limit resets on the next day without anyone touching the total. Changing fakeDay moved the limiter to the next day instantly; in production you would pass () => new Date().toISOString().slice(0, 10) (the UTC date) instead.

Testing through the public functions

Private state cannot be inspected directly, and that is fine: tests should check behaviour through the same functions real callers use. The fake clock makes the day boundary testable in a millisecond:

limit.test.js
import { createTransferLimit } from "./limit.js";

function check(label, actual, expected) {
  const same = JSON.stringify(actual) === JSON.stringify(expected);
  console.log(`${same ? "PASS" : "FAIL"} ${label} -> ${JSON.stringify(actual)}`);
}

function setup(limit = 1000) {
  const clock = { day: "2026-09-24" };
  const limiter = createTransferLimit({ dailyLimitKobo: limit, today: () => clock.day });
  return { clock, limiter };
}

{
  const { limiter } = setup();
  check("exactly the limit is allowed", limiter.authorize(1000), { ok: true });
  check("one kobo more is refused", limiter.authorize(1), { ok: false, reason: "daily limit reached" });
}
{
  const { limiter } = setup();
  for (const bad of [0, -5, 2.5, "500", NaN]) {
    check(`invalid ${JSON.stringify(bad)}`, limiter.authorize(bad).ok, false);
  }
  check("invalid amounts used nothing", limiter.remainingKobo(), 1000);
}
{
  const { limiter } = setup();
  limiter.authorize(800);
  limiter.authorize(300);
  check("refusal does not count", limiter.remainingKobo(), 200);
}
{
  const { clock, limiter } = setup();
  limiter.authorize(1000);
  clock.day = "2026-09-25";
  check("new day resets", limiter.remainingKobo(), 1000);
}
{
  const a = setup().limiter;
  const b = setup().limiter;
  a.authorize(1000);
  check("customers are independent", b.remainingKobo(), 1000);
}
{
  let error = null;
  try {
    setup(0);
  } catch (e) {
    error = e.name;
  }
  check("zero limit rejected", error, "RangeError");
}
Output of node limit.test.js and of the browser terminal
PASS exactly the limit is allowed -> {"ok":true}
PASS one kobo more is refused -> {"ok":false,"reason":"daily limit reached"}
PASS invalid 0 -> false
PASS invalid -5 -> false
PASS invalid 2.5 -> false
PASS invalid "500" -> false
PASS invalid null -> false
PASS invalid amounts used nothing -> 1000
PASS refusal does not count -> 200
PASS new day resets -> 1000
PASS customers are independent -> 1000
PASS zero limit rejected -> "RangeError"

Look at the NaN line: its label says null, because JSON.stringify(NaN) is "null". The test still passed, but the label is misleading. A small reminder that JSON is not a debugging format; String(bad) would have printed NaN. Each test block { … } gets its own block scope, so every test builds fresh limiters and nothing leaks from one test to the next, which is exactly what test runners such as Vitest give you with separate test functions.

In production

  • In-memory state belongs to one process. The limiter above forgets everything on restart, and two server processes would each allow ₦50,000. Real limits are stored in a shared store such as Redis or the database; @zudojs/security's createRateLimiter and the cache lesson show how. The closure design (state plus a clock passed in) carries over unchanged.
  • Module-level maps grow. The limits map above gains an entry per customer and never shrinks. Remove entries for past days, or give the map a size limit, as with memoization.
  • Every cache needs a limit and a reason to be correct. Memoize pure functions only, key on simple values, and bound the size.
  • Every subscribe needs an unsubscribe. Return the cleanup function from wherever you register a listener, and call it in finally.

Practice

TRY IT YOURSELF

A counter with step and reset

Write createTicketCounter(prefix, step = 1). It returns an object with next(), which returns ticket codes like "Q-1", "Q-2" (increasing by step), and reset(), which starts again at the first number. Two counters must not affect each other.

Show a solution
tickets.js
function createTicketCounter(prefix, step = 1) {
  let current = 0;
  return {
    next() {
      current += step;
      return `${prefix}-${current}`;
    },
    reset() {
      current = 0;
    },
  };
}

const queue = createTicketCounter("Q");
const vip = createTicketCounter("VIP", 10);

console.log(queue.next(), queue.next(), vip.next(), vip.next());
queue.reset();
console.log(queue.next(), vip.next());
Output of node tickets.js and of the browser terminal
Q-1 Q-2 VIP-10 VIP-20
Q-1 VIP-30

Each call to createTicketCounter creates its own environment with its own current, prefix and step. next and reset share it.

TRY IT YOURSELF

Fix the handlers

This code registers a discount handler for each coupon, but every handler reports the last coupon. Explain why using environments, and fix it in two different ways.

coupons-bug.js
const coupons = ["SAVE10", "SAVE20", "FREESHIP"];
const handlers = [];
let k = 0;
while (k < coupons.length) {
  handlers.push(() => `applied ${coupons[k - 1]}`);
  k += 1;
}
console.log(handlers.map((h) => h()).join(", "));
Output of node coupons-bug.js and of the browser terminal
applied FREESHIP, applied FREESHIP, applied FREESHIP
Show a solution

There is one k, in the module environment. All three arrows read it when they run, after the loop, when k is 3, so coupons[k - 1] is always the last coupon. Fix it by capturing a value that never changes: a const inside the loop body (a new environment each turn), or a map callback (a new call per item):

coupons-fix.js
const coupons = ["SAVE10", "SAVE20", "FREESHIP"];

const handlers = [];
let k = 0;
while (k < coupons.length) {
  const coupon = coupons[k];
  handlers.push(() => `applied ${coupon}`);
  k += 1;
}
console.log(handlers.map((h) => h()).join(", "));

const viaMap = coupons.map((coupon) => () => `applied ${coupon}`);
console.log(viaMap.map((h) => h()).join(", "));
Output of node coupons-fix.js and of the browser terminal
applied SAVE10, applied SAVE20, applied FREESHIP
applied SAVE10, applied SAVE20, applied FREESHIP

TRY IT YOURSELF

Count the cache hits

Extend memoize(fn) so the memoized function has a stats() method that returns { hits, misses }. A hit is a call answered from the cache; a miss is a call that ran fn.

Show a solution
memo-stats.js
function memoize(fn) {
  const cache = new Map();
  let hits = 0;
  let misses = 0;
  const memoized = (key) => {
    if (cache.has(key)) {
      hits += 1;
      return cache.get(key);
    }
    misses += 1;
    const result = fn(key);
    cache.set(key, result);
    return result;
  };
  memoized.stats = () => ({ hits, misses });
  return memoized;
}

const vatKobo = memoize((priceKobo) => Math.round(priceKobo * 0.075));
[850000, 320000, 850000, 850000, 20000].forEach((price) => vatKobo(price));
console.log(vatKobo.stats());
Output of node memo-stats.js and of the browser terminal
{ hits: 2, misses: 3 }

Functions are objects, so you can attach stats to the memoized function. stats is a closure over the same environment as the cache, so it reads the live counters. Hit rate is the number to watch in production: a cache with almost no hits only costs memory.

Recap

  • Variables live in lexical environments. Every call creates a new one; every function remembers the environment it was created in, and looks names up outwards from there.
  • Functions created in the same call share its environment. A closure sees variables, not snapshots: pass a value as an argument or copy it into a const to lock it.
  • Closures give complete privacy without this. Classes with #private fields share methods through the prototype. Pick by how many objects you create and how they are used.
  • In loops, capture a variable that never changes: a let in the for head, a const in the body, or an array method's parameter. A let outside the loop is still one variable.
  • Factories configure a function once and return it; wrappers like once and memoize add behaviour to any function.
  • Memoize only pure functions, with simple keys and a size limit.
  • A reachable closure keeps its environment alive. Return an unsubscribe function for every subscription, and keep long-lived closures small.

Next: Recursion, where functions call themselves to walk folder trees, comment threads and org charts.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.