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) {
// 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:
- 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 AbortController, Request Dedup, Race Conditions 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 AbortController, Request Dedup, Race Conditions).
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 4
- “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: AbortController, Request Dedup, Race Conditions
- TanStack Query (cache layer): TanStack Query — Cache Keys, Invalidation, Prefetch
- List virtualization: Frontend Performance — Senior Interview Prep
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