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
localStoragefor 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 pattern — aria-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:
- Debounce 200ms — only one request per “quiet” period, not per keystroke.
- AbortController via TanStack Query’s
signal— slow request gets cancelled when a faster one starts. - Cache by query string — typing
"abc"then deleting back to"ab"is a cache hit, no network. enabledgate — don’t fire for 1-char queries (server cost, low signal).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.memoper<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).
Enterto select;Escapeto close + restore previous value.Tabselects highlighted + moves focus on (controversial; pick a convention and stick).Home/Endto 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
- AbortController + race conditions: ../11_apis_data_fetching/05_abort_and_race_conditions.md
- TanStack Query (cache layer): ../11_apis_data_fetching/02_tanstack_query.md
- List virtualization: ../15_performance/
Further reading
- WAI-ARIA Combobox pattern: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
- Downshift: https://www.downshift-js.com/
- Radix Combobox: https://www.radix-ui.com/primitives/docs/components/combobox