advancedVery common6 min read · Updated Jul 19, 2026

Build a Typeahead Controller: Debounce + Cancellation + Stale Guards

Build an autocomplete/typeahead controller in JavaScript that composes debounce, AbortController, result caching, and out-of-order response guards — the classic machine-coding round.


The problem

Build the logic layer of a search-as-you-type box: as the user types, fetch suggestions — without hammering the API on every keystroke, and without ever showing results for an old query.

This is the machine-coding question senior frontend loops have converged on, because it's not a new algorithm — it's a composition test of four things this catalog covers separately, and the grading is whether you know why each layer exists:

  1. Debounce — don't fetch on every keystroke.
  2. AbortController — stop paying for requests you no longer want.
  3. Stale-response guards — the heart of the question: network responses arrive out of order, and abort alone doesn't fully save you.
  4. Caching — repeat queries (backspacing!) shouldn't refetch.

The bug this question exists to catch

t=0    user types "re"  → request A fired
t=80   user types "rea" → request B fired
t=210  B responds (fast server hit) → UI shows results for "rea" ✓
t=340  A responds (slow)            → UI shows results for "re"  ✗✗✗

The user sees results for a query they typed two states ago, with "rea" still in the box. This race is the most common real-world async UI bug in existence, and every interviewer probes for it. There are two independent defenses — use both:

  • Cancel the previous request when a new one starts (saves bandwidth, usually prevents the race).
  • Guard on arrival: tag each request with the query (or a sequence number) and drop any response that doesn't match the latest — because cancellation can lose the race too (the stale response may already be in your .then queue when you abort).

The one-sentence version interviewers reward: abort is an optimization; the last-write-wins guard is the correctness mechanism.

Implementation

Framework-free controller: input events go in, callbacks come out.

function createTypeahead({
  fetchSuggestions,        // (query, { signal }) => Promise<results>
  onResults,               // (results, query) => void
  onError = () => {},      // (error, query) => void — AbortErrors never reach this
  onLoading = () => {},    // (isLoading) => void
  minLength = 2,
  debounceMs = 250,
  cache = new Map(),       // injectable — pass an LRU cache in production
}) {
  let timerId = null;
  let controller = null;   // AbortController for the in-flight request
  let latestQuery = "";    // the ONLY query allowed to touch the UI
 
  async function runSearch(query) {
    if (cache.has(query)) {
      onResults(cache.get(query), query); // sync path: no spinner flash
      return;
    }
 
    controller?.abort();                  // defense 1: cancel the previous fetch
    controller = new AbortController();
    const { signal } = controller;
 
    onLoading(true);
    try {
      const results = await fetchSuggestions(query, { signal });
 
      if (query !== latestQuery) return;  // defense 2: stale guard — the crux
 
      cache.set(query, results);
      onResults(results, query);
    } catch (error) {
      if (error.name === "AbortError") return; // cancelled ≠ failed
      if (query !== latestQuery) return;       // stale errors are noise too
      onError(error, query);
    } finally {
      if (query === latestQuery) onLoading(false);
    }
  }
 
  return {
    onInput(rawQuery) {
      const query = rawQuery.trim();
      latestQuery = query;
      clearTimeout(timerId);
 
      if (query.length < minLength) {
        controller?.abort();      // user cleared the box: stop everything
        onResults([], query);
        onLoading(false);
        return;
      }
      timerId = setTimeout(() => runSearch(query), debounceMs);
    },
 
    destroy() {                   // unmount: no timers, no fetches, no callbacks
      clearTimeout(timerId);
      controller?.abort();
      latestQuery = "�";     // sentinel no input can equal — all guards fail closed
    },
  };
}

Wiring it up

const typeahead = createTypeahead({
  fetchSuggestions: (q, { signal }) =>
    fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal })
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      }),
  onResults: renderList,
  onLoading: toggleSpinner,
});
 
input.addEventListener("input", (e) => typeahead.onInput(e.target.value));

Why each decision is load-bearing

  • latestQuery updates immediately, in onInputbefore the debounce. The guard must compare against what the user wants now, not what last got fetched. Updating it inside runSearch reopens the race for responses that span a debounce window.
  • Cache check before abort/fetch — cache hits render synchronously with no spinner flicker, and backspacing ("rea""re") is the common case that hits it. The injectable cache parameter is deliberate: production wants an LRU with TTL, tests want a plain Map — policy stays out of the controller (the same separation as memoize).
  • AbortError swallowed silently — cancellation is the expected outcome of fast typing, not an error state. Routing it to onError makes the UI flash error toasts while the user types — a real shipped bug.
  • onLoading(false) only for the latest query — otherwise a stale request's finally kills the spinner for the new in-flight request.
  • destroy() — component unmount must stop timers, abort fetches, and neutralize callbacks; the debounce-in-React cleanup discipline (same as debounce's cancel) applied to the whole controller.
  • encodeURIComponent"C++ jobs" and "a&b" must not corrupt the query string. One token of security/correctness hygiene interviewers notice.

Edge cases interviewers probe

  • Out-of-order responses — the headline race; expect to whiteboard the timeline above.
  • Backspace to a previous query — cache hit; also note the subtlety that a stale-guarded map cache stores only completed queries, so no poisoning.
  • Query shorter than minLength mid-flight — clear results and abort; forgetting the abort leaves a response that repopulates the list after clearing.
  • Fast typing across a slow network — debounce collapses keystrokes; abort collapses requests; the guard catches whatever slips through. Three layers, three different failure modes — be able to say which layer catches which.
  • Racing the empty state — user types, then selects a suggestion, then results for the typed query arrive. Selection should update latestQuery (or destroy/reset) — the guard framework already handles it if selection routes through the controller.

Common mistakes

  • Debounce only, no stale guard — the classic "works on localhost, glitches on 3G" answer.
  • Abort only, no guard — misses the already-queued-response window.
  • Comparing against the input DOM value inside the response handler — couples the controller to the DOM and still races with the debounce window; the explicit latestQuery state is the clean version.
  • Sequence numbers implemented as "ignore if id < latest" but reset on cache hits/clears — query-string comparison is harder to get wrong than counter bookkeeping.
  • Showing AbortErrors to the user.
  • No destroy — the unmounted-component fetch is the interview's follow-up trap.

Follow-up questions

  • "Add keyboard navigation and highlight." — the UI half of the machine-coding round: active-index state, aria-activedescendant, and the combobox ARIA pattern — mention role="combobox"/aria-expanded and you've covered the accessibility grading line.
  • "Race-proof this with a sequence number instead of the query string — trade-offs?" — sequence numbers also reject equal-query reorders (rare, benign) and survive query normalization changes; strings self-describe in logs. Either passes; comparing them is the senior move.
  • "Where does AbortSignal.timeout fit?" — deadline per request combined with user-driven aborts via AbortSignal.any([userSignal, AbortSignal.timeout(5000)]) — the modern composition (cancellation article).
  • "How would React Query/SWR change this?" — they provide the cache, dedupe, and stale handling; the debounce and latestQuery UX logic remain yours. Knowing what the library does not solve is the point.
  • "Server-side: how do search backends handle typeahead load?" — prefix indexes/tries, result caching by prefix, and request coalescing — the systems bridge if the interviewer wants to go up a level.

  • IntersectionObserver, in-flight guards, end-of-data states, and the sentinel pattern — the pagination question every feed team asks.

  • LRU Cache

    advanced

    O(1) get/put with least-recently-used eviction — the Map insertion-order trick, and the linked-list version interviewers ask about.

  • Star Rating Widget

    intermediate

    A dependency-free rating component: event delegation, hover preview vs committed state, keyboard support, and the ARIA pattern.

  • A concurrency-limited scheduler with priorities and cancellation — the pool question upgraded to the API-design round.