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 });
| Pattern | Use it when | Captured state |
|---|---|---|
debounce | you only care about the final value (search input, resize) | a timer id |
throttle | you need regular updates but not every event (scroll, mousemove) | a timestamp |
memoise | a pure function is called repeatedly with the same arguments | a cache |
once | initialisation that must not run twice | a boolean and a result |
| module pattern | you want privacy without a class | everything 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
memoisekeep 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
memoisegives every wrapped function its own, which is both correct and easier to reason about. - You attach a debounced handler to
inputwith 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 — AWeakMapholds 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 aonce-wrapped function has run.0,null,falseand''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
onceare 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.