Skip to main content
CodeOath
← All posts

JavaScript140 min total · 15 parts

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

Part 6 of 15 · ~5 min

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

Here is the thing about closures: people file them under "interview question", when in fact three of the most load-bearing utilities in front-end code are nothing but closures with good names. Our search box needs all three, and it needs them for reasons you can feel.

Start with what is actually wrong with the current version. Type closures into the box — eight characters, eight input events, eight full passes over the article index, eight DOM rewrites. Delete a character and retype it and we redo work we already did. Scroll the dropdown and a scroll handler fires a hundred times a second. All three problems, all three closures.

Debounce is the one the search box needs most urgently. The idea is simple to state: wait until the typing stops before doing anything.

function debounce(fn, delay) {
  let timeoutId; // captured — survives between calls, which is the entire trick
  return function (...args) {
    clearTimeout(timeoutId);       // cancel the previous plan
    timeoutId = setTimeout(() => fn(...args), delay); // make a new one
  };
}

const debouncedSearch = debounce((query) => renderRows(search(query)), 300);
inputEl.addEventListener("input", (e) => debouncedSearch(e.target.value));

Every keystroke cancels the pending search and schedules a fresh one 300ms out. Type closures at any human speed and the first seven keystrokes each cancel the one before; only the eighth survives to fire. Eight passes become one.

The part that makes it work is timeoutId. It is declared inside debounce, which has already returned — so the only reason it still exists is that the returned function closed over it. Without that, each call would have its own timeoutId, nothing would ever get cancelled, and debounce would be an elaborate way to add a delay.

Throttle is the sibling, and the difference matters. Debounce waits for quiet; throttle guarantees a maximum rate. Our dropdown loads more results as you scroll, and a scroll handler fires far too often to run real work in:

function throttle(fn, interval) {
  let lastRun = 0; // captured, same as above
  return function (...args) {
    const now = Date.now();
    if (now - lastRun >= interval) {
      lastRun = now;
      fn(...args);
    }
  };
}

listEl.addEventListener("scroll", throttle(maybeLoadMorePages, 200));

Choose between them by asking what you want at the end of a burst. Debounce fires once, after it stops — right for search-as-you-type, where the intermediate queries are genuinely worthless. Throttle fires steadily during — right for scroll and resize, where you want to keep reacting while it happens, just not sixty times a second.

Memoization handles the third problem: work we have already done. Users backspace and retype constantly, and searching for clos twice should not cost twice.

function memoize(fn) {
  const cache = new Map(); // captured by the returned function, persists across every call
  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 search = memoize(function search(query) {
  return ARTICLES.filter((a) => normalize(a.title).includes(normalize(query)));
});

search("clos"); // walks the whole index
search("clos"); // returns instantly from the closure's cache

Two caveats worth carrying with you, since this is the pattern most likely to go wrong in production. JSON.stringify as a cache key is fine for the primitive arguments we have here and quietly wrong for anything with a non-deterministic key order or a non-serialisable value. And that Map grows forever — for a search box, where a user might type a few hundred distinct queries in a session, that is fine; for something with unbounded inputs you want an eviction policy, or you have built a memory leak with a nice name.

All three utilities are the same shape. A function returns another function, and the returned one keeps a private variable alive between calls — timeoutId, lastRun, cache. That private-memory-between-calls property is the closure. Once you see it once, you see it in every debounce implementation you will ever read.

The module pattern

Our search box now has four pieces of private state — latestQuery, recentQueries, cache, timeoutId — and a handful of functions that need to share them while the rest of the application keeps its hands off. That is a module, and before import/export existed, closures were how you built one:

const searchBox = (function () {
  let latestQuery = "";           // genuinely unreachable from outside
  const cache = new Map();        // likewise
  const recentQueries = [];

  function search(query) { /* reads cache, ARTICLES */ }
  function render(matches) { /* reads listEl */ }

  return {
    handleInput(event) {
      latestQuery = normalize(event.target.value);
      recentQueries.push(latestQuery);
      render(search(latestQuery));
    },
    clearCache() { cache.clear(); },
  };
})();

searchBox.handleInput(someEvent); // works
searchBox.latestQuery;            // undefined — not hidden, genuinely absent from the returned object
searchBox.cache;                  // undefined — same

The IIFE runs once, creates one scope, and returns an object holding functions that closed over it. Everything not on that returned object is unreachable, permanently, from anywhere in the program.

ES modules do this at the language level now — anything a file does not export is private to it, no ceremony required — and that is what you should write today. But the pattern is worth recognising for two reasons. You will meet it constantly in pre-2015 code, minified bundles, and anything shipped as a standalone <script>. And it is the clearest possible demonstration that module privacy in JavaScript was always just closures. Nothing was added to the language to make it work. Somebody noticed that a returned function keeps its birthplace alive, and built an encapsulation system out of it.