InterviewsVector
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 onInput — before 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.