Frontend / Frontend system design / 01_typeahead_autocomplete.md

Design: Typeahead / Autocomplete

Updated 4 interview angles 5 min read source
On this page13
  1. Requirements to clarify
  2. API contract
  3. Client data model
  4. Component architecture
  5. The networking pattern
  6. Rendering & perf
  7. Keyboard a11y — non-negotiable
  8. Failure modes
  9. Caching beyond the request
  10. When you’d push to the server vs do client-side
  11. Related
  12. Further reading
  13. Interview angle

Design: Typeahead / Autocomplete

A search input that suggests results as the user types. Looks trivial, hides every networking pitfall in one component: debouncing, abort, race conditions, caching, keyboard a11y, and rendering latency. The senior answer covers all of them in the first pass without prompting.

Requirements to clarify

  • Latency target. Sub-100ms keystroke-to-suggestion or “good enough”? Drives whether you can hit the server every keystroke or need client-side caching/prefix tree.
  • Result count and shape. 10 suggestions or 50? Plain strings, or rich items with avatars + descriptions?
  • Match algorithm. Prefix, substring, fuzzy? On client or server?
  • Recency / personalization. Recently-viewed/searched promoted?
  • Multi-section results. “People,” “Files,” “Channels” — Slack-style grouped suggestions?
  • Result freshness. Cache for 5 minutes acceptable, or must reflect immediate writes?

API contract

text
GET /api/search?q=<query>&limit=10&types=user,doc
→ 200 {
    "groups": [
      { "type": "user", "items": [{ "id": "u1", "name": "Ada Lovelace", "avatar": "..." }] },
      { "type": "doc",  "items": [...] }
    ]
  }

Use GET so responses can be cached at the CDN and by TanStack Query. Server enforces query length limits (reject q="" to avoid hot dump-everything queries).

Client data model

  • Server cache (TanStack Query) keyed by ["search", query, types] — automatic dedup of repeat queries, prefix-keyed invalidation.
  • Recent searches in localStorage for instant fallback when the user opens the box but hasn’t typed.
  • UI state (open, highlightedIndex, selected) in local component state.

Component architecture

text
<Combobox>
  <Input
    value, onChange
    onKeyDown={arrow/enter/escape}
    aria-expanded, aria-controls, aria-activedescendant
  />
  <Listbox role="listbox" id="suggestions">
    {results.map((item, i) => (
      <Option role="option" aria-selected={i === highlightedIndex} />
    ))}
  </Listbox>
</Combobox>

Use WAI-ARIA Combobox pattern — aria-expanded, aria-controls, aria-activedescendant. Don’t roll your own; the patterns are subtle. Either follow the spec exactly or use Downshift, Headless UI or Ariakit - note that Radix Primitives ships no Combobox.

The networking pattern

tsx
function useTypeahead(query: string) {
  // 200ms quiet
  const debounced = useDebouncedValue(query, 200);
  return useQuery({
    queryKey: ["search", debounced],
    queryFn: ({ signal }) => fetch(`/api/search?q=${debounced}`, { signal }).then(r => r.json()),
    enabled: debounced.length >= 2,
    staleTime: 60_000,
    // smooth UI during refetch
    keepPreviousData: true,
  });
}

Five things this gets right:

  1. Debounce 200ms — only one request per “quiet” period, not per keystroke.
  2. AbortController via TanStack Query’s signal — slow request gets cancelled when a faster one starts.
  3. Cache by query string — typing "abc" then deleting back to "ab" is a cache hit, no network.
  4. enabled gate — don’t fire for 1-char queries (server cost, low signal).
  5. keepPreviousData — previous results stay visible during refetch so the UI doesn’t flicker to empty.

See Race conditions and AbortController for why debounce + abort is the combined answer.

Rendering & perf

  • Render <= 50 items, even if the server returns more. After 50 the user is scanning, not reading; paginate or show “+ 12 more.”
  • <VirtualList> if you really need long lists (commands palette with 5K commands).
  • Memoize item rendering when the result shape includes heavy nodes (avatars, icons). React.memo per <Option> with referential-stable item props.
  • Highlight matching characters in the rendered text — cheap, big UX win. Pre-compute on the server or do on the client with a small string-diff.

Keyboard a11y — non-negotiable

  • ↑/↓ to move highlight (wraps at ends? configurable).
  • Enter to select; Escape to close + restore previous value.
  • Tab selects highlighted + moves focus on (controversial; pick a convention and stick).
  • Home/End to jump.
  • Screen reader: live region announces “5 results available” on the suggestion list opening.

Failure modes

  • Network slow — keep input responsive; show a tiny spinner; never block typing.
  • Server returns 500 — silently fall back to “recent searches”; never show a stack trace.
  • Server returns empty — friendly “no matches” with the search term echoed back.
  • Rate-limited (429) — back off; surface “too many requests, slow down” only if it persists.
  • User pastes a giant string — clamp client-side length before sending; reject server-side.
  • Race condition: result for "ab" arrives after "abc" — already prevented by TanStack Query (it keys by query and ignores stale responses). Without TanStack Query, generation counter (see Race conditions and AbortController).

Caching beyond the request

  • CDN cache on /api/search?q= if results are public (Cache-Control: public, s-maxage=30). A million users typing "the" should hit a CDN edge, not your origin.
  • Service worker can serve recent results offline.
  • Client trie for “search inside this static dataset” (the Stripe-style command palette over a few hundred items). Pre-build the trie at app boot, no server needed.

When you’d push to the server vs do client-side

  • Server-side: large catalogs, fuzzy/relevance scoring, ACL filtering, anything requiring index access.
  • Client-side: bounded datasets (≤ ~10K items), instant latency requirement (no network), offline support, personalized re-ranking on top of server results.

Hybrid: server returns top 50, client re-ranks/filters as the user keeps typing within that result set.

Further reading

Interview angle 4

  • “Design a typeahead. What are the moving parts?” - debounce, abort and cache, and all three rather than one. Debounce cuts how many requests you send, abort cancels the one you superseded, and a cache keyed by query makes backspacing instant because the earlier result is still there.
  • “What is the bug juniors hit here?” - the stale response. Two requests in flight, the older one lands second, and the list shows results for a query the user has moved past. A query library keyed by the exact query fixes it structurally, because a response for a key nothing is subscribed to has nowhere to write.
  • “How do you make it accessible?” - the WAI-ARIA combobox pattern: aria-expanded and aria-controls on the input, aria-activedescendant pointing at the highlighted option, and the option list with the right roles. Use Downshift or React Aria rather than hand-rolling - the keyboard and screen reader nuances are a genuine long tail.
  • “What would you measure?” - keystroke to first painted suggestion, requests per session as the cost signal, and abandonment rate as the outcome. The first two trade against each other, and the third tells you which way to trade.