Closure Patterns in Real Code

Mental model: Most utility functions you use daily are a closure with a good name.

Level: intermediate · about 16 minutes

Once closures click, a whole category of utility functions stops looking like library magic. Here are the five you will meet most often, each about ten lines.

1. The module pattern

const store = (function () {
  const items = [];                       // private

  function validate(item) {               // private helper
    if (!item?.id) throw new Error('items need an id');
  }

  return {
    add(item) { validate(item); items.push(item); return items.length; },
    all() { return [...items]; },          // a copy, so callers cannot mutate ours
    get size() { return items.length; },
  };
})();

store.add({ id: 1 });
store.add({ id: 2 });
console.log(store.size, store.all());
console.log(store.items); // undefined — not exposed

2. Factory functions

const createLogger = (prefix, { silent = false } = {}) => ({
  info: (msg) => silent || console.log(`[${prefix}] ${msg}`),
  warn: (msg) => silent || console.warn(`[${prefix}] ${msg}`),
});

const log = createLogger('auth');
log.info('signed in');

const quiet = createLogger('metrics', { silent: true });
quiet.info('this goes nowhere');

3. Memoisation — trade memory for time

function memoise(fn) {
  const cache = new Map();               // captured, private, persistent
  return function (...args) {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn.apply(this, args));
    return cache.get(key);
  };
}

let calls = 0;
const slowSquare = (n) => { calls++; return n * n; };
const fast = memoise(slowSquare);

console.log(fast(9), fast(9), fast(9));
console.log('actual calls:', calls);   // 1

4. Debounce — wait until they stop

function debounce(fn, ms = 300) {
  let timer;                             // the captured state
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), ms);
  };
}

// One search request after typing stops, instead of one per keystroke.
input.addEventListener('input', debounce((e) => search(e.target.value), 250));

5. Throttle — at most once per interval

function throttle(fn, ms = 100) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= ms) {
      last = now;
      fn.apply(this, args);
    }
  };
}

window.addEventListener('scroll', throttle(updateHeader, 100), { passive: true });
PatternUse it whenCaptured state
debounceyou only care about the final value (search input, resize)a timer id
throttleyou need regular updates but not every event (scroll, mousemove)a timestamp
memoisea pure function is called repeatedly with the same argumentsa cache
onceinitialisation that must not run twicea boolean and a result
module patternyou want privacy without a classeverything not returned
function once(fn) {
  let done = false, result;
  return (...args) => {
    if (!done) { done = true; result = fn(...args); }
    return result;
  };
}
const init = once(() => { console.log('init'); return 1; });
console.log(init(), init(), init());

done flips on the first call, so fn never runs again, but result was captured and is returned every time. Note the order: init() calls happen before console.log prints, so "init" appears first.

Try it yourself

Debounce versus throttle, side by side

const debounce = (fn, ms = 300) => {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
};

const throttle = (fn, ms = 100) => {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= ms) { last = now; fn(...args); }
  };
};

const debounced = debounce((n) => console.log('debounced saw', n), 120);
const throttled = throttle((n) => console.log('throttled saw', n), 120);

// Simulate 10 rapid "keystrokes", 40ms apart.
for (let i = 1; i <= 10; i++) {
  setTimeout(() => { debounced(i); throttled(i); }, i * 40);
}
console.log('debounce fires once at the end; throttle fires a few times along the way');

Change the intervals. Then add a leading: true option to debounce so it fires immediately and then waits.

Exercises

Implement once

Write once(fn) which returns a wrapper that calls fn at most one time. Every later call returns the first result without calling fn again. The wrapper must forward its arguments.

Implement memoise with a cache limit

Write memoise(fn, limit = 3). Cache results by argument list. When the cache exceeds limit entries, drop the oldest one. Expose cacheSize() on the returned function so the tests can inspect it.

Check yourself

Why does memoise keep its cache in a closure instead of a module-level variable?
So each memoised function gets its own independent cache — One shared module-level cache would mix results from unrelated functions. Capturing the cache per call to memoise gives every wrapped function its own, which is both correct and easier to reason about.
You attach a debounced handler to input with a 300 ms delay. The user types 10 characters quickly. How many times does the underlying function run?
1 — Each keystroke clears the pending timer and starts a new one, so only the last one survives to fire. That is the point of debounce: you only care about the final value.
Which is the safer eviction policy for a memoisation cache keyed by objects?
A WeakMap, so entries disappear when the key object is collected — A WeakMap holds its keys weakly, so caching metadata against an object does not keep that object alive. An ever-growing plain cache is one of the classic JavaScript memory leaks.

Common mistakes

  • Using if (!result) to decide whether a once-wrapped function has run. 0, null, false and '' are all legitimate results.
  • Unbounded memoisation caches, which leak steadily and invisibly.
  • Creating a new debounced function on every render, so the timer never survives long enough to fire.

Takeaways

  • Private state, factories, memoisation, debounce, throttle and once are all the same idea wearing different hats.
  • Whatever you do not return from a factory is genuinely inaccessible.
  • Every cache needs an eviction policy, or it is a leak.