Design an Autocomplete

Hard

Designing an autocomplete/typeahead component is a deceptively complex frontend system design problem. It involves debounced input handling, asynchronous search with race condition management, keyboard navigation, accessibility with ARIA combobox patterns, caching of previous results, and handling edge cases like network failures and empty states.

Interactive Visualization

Key Points

  • Debounce input to avoid firing API requests on every keystroke (typically 200-300ms)
  • AbortController cancels in-flight requests when the user types faster than the API responds
  • Request deduplication serves cached results for repeated queries
  • ARIA combobox pattern: role="combobox", aria-expanded, aria-activedescendant for screen readers
  • Keyboard navigation: ArrowUp/Down to navigate, Enter to select, Escape to close
  • Highlight matching text in results to show why each result matched
  • Handle edge cases: empty query, no results, network error, minimum character threshold
  • Recent searches and trending queries provide value before the user types anything

Code Examples

Debounced Search with Race Condition Handling

// Core autocomplete hook with debounce and abort
function useAutocomplete(
  searchFn: (query: string, signal: AbortSignal) => Promise<SearchResult[]>,
  options = { debounceMs: 250, minChars: 2 }
) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<SearchResult[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<Error | null>(null)
  const abortRef = useRef<AbortController | null>(null)
  const cacheRef = useRef<Map<string, SearchResult[]>>(new Map())

  const debouncedSearch = useMemo(
    () => debounce(async (q: string) => {
      if (q.length < options.minChars) {
        setResults([])
        setIsLoading(false)
        return
      }

      // Check cache first
      const cached = cacheRef.current.get(q)
      if (cached) {
        setResults(cached)
        setIsLoading(false)
        return
      }

      // Abort previous in-flight request
      abortRef.current?.abort()
      const controller = new AbortController()
      abortRef.current = controller

      try {
        const data = await searchFn(q, controller.signal)
        // Only update if this request was not aborted
        if (!controller.signal.aborted) {
          cacheRef.current.set(q, data)
          setResults(data)
          setError(null)
        }
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err)
        }
      } finally {
        if (!controller.signal.aborted) setIsLoading(false)
      }
    }, options.debounceMs),
    [searchFn, options.debounceMs, options.minChars]
  )

  const handleChange = useCallback((value: string) => {
    setQuery(value)
    setIsLoading(value.length >= options.minChars)
    debouncedSearch(value)
  }, [debouncedSearch, options.minChars])

  return { query, results, isLoading, error, handleChange }
}

The core challenge is handling race conditions: when the user types "rea" then "reac", the response for "rea" might arrive after "reac". AbortController cancels stale requests, and the cache prevents redundant API calls.

Accessible Combobox with Keyboard Navigation

// ARIA combobox pattern with keyboard support
function Autocomplete({ onSelect, searchFn }: AutocompleteProps) {
  const { query, results, isLoading, handleChange } = useAutocomplete(searchFn)
  const [activeIndex, setActiveIndex] = useState(-1)
  const [isOpen, setIsOpen] = useState(false)
  const listboxId = useId()
  const inputRef = useRef<HTMLInputElement>(null)

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(prev =>
          prev < results.length - 1 ? prev + 1 : 0
        )
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(prev =>
          prev > 0 ? prev - 1 : results.length - 1
        )
        break
      case 'Enter':
        e.preventDefault()
        if (activeIndex >= 0 && results[activeIndex]) {
          onSelect(results[activeIndex])
          setIsOpen(false)
        }
        break
      case 'Escape':
        setIsOpen(false)
        setActiveIndex(-1)
        inputRef.current?.focus()
        break
    }
  }

  const activeDescendantId = activeIndex >= 0
    ? `${listboxId}-option-${activeIndex}`
    : undefined

  return (
    <div role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
      <input
        ref={inputRef}
        type="text"
        value={query}
        onChange={e => { handleChange(e.target.value); setIsOpen(true) }}
        onKeyDown={handleKeyDown}
        onFocus={() => setIsOpen(true)}
        aria-autocomplete="list"
        aria-controls={listboxId}
        aria-activedescendant={activeDescendantId}
        role="searchbox"
      />

      {isOpen && (
        <ul id={listboxId} role="listbox" aria-label="Search suggestions">
          {isLoading && (
            <li role="status" aria-live="polite">Searching...</li>
          )}
          {results.map((result, index) => (
            <li
              key={result.id}
              id={`${listboxId}-option-${index}`}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => { onSelect(result); setIsOpen(false) }}
              onMouseEnter={() => setActiveIndex(index)}
            >
              <HighlightMatch text={result.title} query={query} />
            </li>
          ))}
          {!isLoading && results.length === 0 && query.length >= 2 && (
            <li role="status">No results found</li>
          )}
        </ul>
      )}
    </div>
  )
}

The ARIA combobox pattern requires specific roles, properties, and keyboard interactions. aria-activedescendant tells the screen reader which option is focused without moving DOM focus away from the input.

Autocomplete Architecture Diagram

// Complete autocomplete architecture
//
// User Input → Debounce (250ms) → Cache Check
//                                   ↓ miss
//                              AbortController
//                                   ↓
//                              API Request
//                                   ↓
//                              Cache Store
//                                   ↓
//                              Results State
//                                   ↓
//                         ┌─────────────────────┐
//                         │  Dropdown Panel       │
//                         │  ┌─────────────────┐ │
//                         │  │ Recent Searches  │ │  (query = '')
//                         │  ├─────────────────┤ │
//                         │  │ Suggestions      │ │  (query.length >= 2)
//                         │  │  → Highlighted   │ │
//                         │  │  → Highlighted   │ │
//                         │  │  → Highlighted   │ │
//                         │  ├─────────────────┤ │
//                         │  │ "See all results"│ │
//                         │  └─────────────────┘ │
//                         └─────────────────────┘

// Key architectural decisions:
// 1. Debounce: 200-300ms prevents excessive API calls
// 2. AbortController: cancel stale requests on new input
// 3. LRU cache: store last N query results client-side
// 4. Minimum chars: require 2+ characters before searching
// 5. Keyboard nav: full arrow key + Enter + Escape support
// 6. ARIA combobox: accessible to screen readers
// 7. Highlight matches: show why each result matched
// 8. Recent searches: value before typing (stored in localStorage)
// 9. Error boundary: graceful degradation on API failure

interface AutocompleteConfig {
  debounceMs: number        // 250ms
  minChars: number          // 2
  maxResults: number        // 8-10
  cacheSize: number         // 50 queries
  cacheTTL: number          // 5 minutes
  showRecent: boolean       // true
  maxRecent: number         // 5
  highlightMatches: boolean // true
  submitOnSelect: boolean   // depends on use case
}

The autocomplete architecture balances responsiveness (debounce, cache), correctness (abort stale requests, race conditions), accessibility (ARIA combobox), and user experience (recent searches, match highlighting).

Common Mistakes

  • Not debouncing input, sending an API request on every keystroke
  • Ignoring race conditions: stale API responses overwriting fresh results
  • Using role="listbox" without implementing keyboard navigation (ArrowUp/Down, Enter, Escape)
  • Not handling the empty state, loading state, and error state in the dropdown
  • Forgetting to close the dropdown on blur/Escape and on selection

Interview Tips

  • Start with the RADIO framework: requirements first, then architecture
  • Draw the data flow: input > debounce > cache > API > results > render
  • Discuss race condition handling with AbortController explicitly
  • Mention accessibility as a first-class concern: ARIA combobox is expected
  • Discuss caching strategy: LRU cache with TTL for repeated queries

Related Concepts