Closures aren't just an interview topic — three patterns built directly on them show up constantly in real code.
Memoization caches a function's results, keyed by its arguments, using a closure to hold the cache between calls:
function memoize(fn) {
const cache = new Map(); // captured by the returned function, persists across calls
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
const slowSquare = (n) => { for (let i = 0; i < 1e8; i++); return n * n; };
const fastSquare = memoize(slowSquare);
fastSquare(5); // slow the first time
fastSquare(5); // instant — read from the closure's cache
Debounce delays running a function until a burst of calls has stopped for a given interval — the standard fix for a search-as-you-type input firing a network request on every keystroke:
function debounce(fn, delay) {
let timeoutId; // captured by the closure, survives between calls
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
const debouncedSearch = debounce((query) => fetchResults(query), 300);
input.addEventListener("input", (e) => debouncedSearch(e.target.value));
// only fires fetchResults 300ms after the user stops typing, not on every keystroke
Throttle is debounce's sibling — instead of waiting for a pause, it guarantees a function runs at most once per interval, useful for a scroll or resize handler that would otherwise fire hundreds of times a second:
function throttle(fn, interval) {
let lastRun = 0; // captured by the closure
return function (...args) {
const now = Date.now();
if (now - lastRun >= interval) {
lastRun = now;
fn(...args);
}
};
}
All three work for the same underlying reason: the returned function closes over a variable (cache, timeoutId, lastRun) that persists across every call, giving the function memory it wouldn't otherwise have.
Before ES modules (import/export) existed, an IIFE (immediately invoked function expression) was the standard way to create private state and expose only a specific public interface — closures are what make the "private" part actually private:
const counterModule = (function () {
let count = 0; // truly inaccessible from outside — no closure over it exists elsewhere
return {
increment() { return ++count; },
reset() { count = 0; },
};
})();
counterModule.increment(); // 1
counterModule.count; // undefined — there's no way to reach the real variable directly
ES modules now handle this at the language level (anything not exported is private to the file), but the module pattern is still worth recognizing in older codebases, and it's the same closure mechanism underneath either way.