frontend / frontend system design / 01_typeahead_autocomplete.md

Design: Typeahead / Autocomplete

4 min read source

Design: Typeahead / Autocomplete

TL;DR

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

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

<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 patternaria-expanded, aria-controls, aria-activedescendant. Don’t roll your own; the patterns are subtle. Either follow the spec exactly or use Radix Combobox / Headless UI / Downshift.

The networking pattern

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

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 ../11_apis_data_fetching/05_abort_and_race_conditions.md 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 ../11_apis_data_fetching/05_abort_and_race_conditions.md).

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.

What a senior is expected to say

  • “Debounce + abort + cache, not one or the other. Debounce reduces fires; abort kills in-flight; cache makes back-typing instant.”
  • “Keys: I cache by the exact query, and let TanStack Query handle dedup and stale-response dropping. The race condition that catches juniors is fixed for free.”
  • “WAI-ARIA Combobox pattern — aria-expanded / aria-controls / aria-activedescendant. I’d use Downshift or Radix rather than hand-roll because the keyboard nuances are real.”
  • “Telemetry: keystroke-to-first-paint latency, request count per session, abandonment rate.”

Cross-references

Further reading