CodeOath
← All posts
JavaScript75 min total · 15 parts

JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop

Contents — Part 6 of 15: Practical Closure Patterns: Memoization, Debounce, and the Module Pattern
Part 6 of 15 · ~2 min

Practical Closure Patterns: Memoization, Debounce, and the Module Pattern

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.

The module pattern

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.