QuestionsSystem Design

Design an autocomplete / typeahead

Autocomplete / TypeaheadMediumSystem Design

Design a search autocomplete. Apply RADIO; call out debouncing, race handling, caching, and a11y.

What it tests

Debounce, request cancellation, out-of-order response handling, caching, and the combobox a11y pattern.

Approach & answer

Requirements: as-you-type suggestions that are fast and CURRENT (no stale result overwriting a newer one), keyboard-navigable, accessible, and resilient to a slow API. Architecture: the input drives a debounced query; each query cancels the previous in-flight request; results render in a listbox the input controls. Data model: { query, results, activeIndex, loading } plus a cache mapping query→results. Interface: GET /suggest?q=… returning ranked items. Optimizations: DEBOUNCE input (~150–300ms) so you fetch per pause, not per keystroke; CANCEL the superseded request (AbortController) AND guard against out-of-order responses by ignoring any response that isn't for the latest query — this race is the #1 autocomplete bug (an earlier, slower response clobbers a newer one); CACHE by query string (reuse prefixes) so backtracking is instant; cap and rank results; skip requests for empty or trivially short input. Accessibility is a spec, not a nicety — the combobox pattern: role=combobox on the input, aria-expanded, aria-activedescendant pointing at the highlighted option, ↑/↓ to move, Enter to select, Esc to close, each option role=option. Show loading/empty/error states. Why cancel AND a latest-query guard together: cancellation frees the network, but a cancel can land after a response has already started, so you still must gate on 'is this the current query?' — sequencing by the query value (or a request id) is what actually prevents flicker.

Use this technique when

Search boxes, @mention pickers, address/command palettes — any type-to-search with a network backend.

Code

function Autocomplete({ search }) {
  const [q, setQ] = React.useState('');
  const [results, setResults] = React.useState([]);
  const latest = React.useRef(0);

  React.useEffect(() => {
    if (!q) { setResults([]); return; }
    const id = ++latest.current;                    // sequence this request
    const ctrl = new AbortController();
    const t = setTimeout(async () => {              // debounce
      const data = await search(q, ctrl.signal);
      if (id === latest.current) setResults(data);  // ignore stale responses
    }, 200);
    return () => { clearTimeout(t); ctrl.abort(); };
  }, [q, search]);

  return (
    <div>
      <input role="combobox" aria-expanded={results.length > 0}
             value={q} onChange={(e) => setQ(e.target.value)} />
      <ul role="listbox">
        {results.map((r) => <li role="option" key={r.id}>{r.label}</li>)}
      </ul>
    </div>
  );
}

References