Frontend System Design: Autocomplete / Typeahead

interviewMarch 28, 2026

The autocomplete (or typeahead) question is the most common frontend system design question asked at companies like Google, Meta, Airbnb, and Uber. It looks deceptively simple — "show suggestions as the user types" — but a complete answer covers architecture, state management, performance, accessibility, and API design.

This article walks through a full RADIO-framework answer. If you're not familiar with RADIO (Requirements, Architecture, Data Model, Interface, Optimisations), read the introduction first.

R — Requirements

Start by clarifying scope with the interviewer. Don't assume.

Functional requirements:

  • Show a dropdown of suggestions as the user types
  • Results update with each keystroke (with debounce)
  • Support keyboard navigation (↑↓ to navigate, Enter to select, Escape to close)
  • Clicking a suggestion navigates to or selects the result
  • Loading state while fetching; error state if the API fails
  • Empty state when no results match

Non-functional requirements:

  • Suggestions should feel instant — target <150ms perceived latency
  • Works on mobile (touch) and desktop (keyboard)
  • Accessible to screen reader users
  • Handles rapid typing gracefully — no stale results
  • Reusable across different search surfaces (global search, within a form, etc.)

Out of scope (for now):

  • Spell correction
  • Personalised results
  • Search history / recently visited
  • Multi-select

A — Architecture

Autocomplete component architecture — UI layer, useAutocomplete hook, API layer

The autocomplete has three main UI concerns: input management, suggestion rendering, and data fetching. Here's how to break it down:

┌─────────────────────────────────────────┐
│           <Autocomplete>                │
│  ┌──────────────────────────────────┐   │
│  │        <SearchInput>             │   │
│  │  [ 🔍  Type to search...      ] │   │
│  └──────────────────────────────────┘   │
│  ┌──────────────────────────────────┐   │
│  │     <SuggestionDropdown>         │   │
│  │  ┌────────────────────────────┐  │   │
│  │  │  <SuggestionItem> (×N)     │  │   │
│  │  └────────────────────────────┘  │   │
│  └──────────────────────────────────┘   │
└─────────────────────────────────────────┘
         │
         │ useAutocomplete() hook
         │
┌────────┴────────────────────────────────┐
│  State: query, suggestions, activeIndex, status │
│  Logic: debounce, fetch, cache, keyboard nav    │
└─────────────────────────────────────────────────┘
         │
         ▼
   GET /api/search?q={query}

Components:

  • Autocomplete — root, wires everything together
  • SearchInput — controlled input with ARIA attributes
  • SuggestionDropdownul list, visible when open
  • SuggestionItem — individual result row

Logic (hook):

  • useAutocomplete — owns all state + side effects: debouncing, fetching, caching, keyboard navigation

Separating logic into a custom hook makes the component reusable and testable independently of the UI.

D — Data Model

API Response

// GET /api/search?q=react&limit=8
type Suggestion = {
  id: string;
  label: string;          // Display text
  url: string;            // Where selecting takes you
  category?: string;      // Optional grouping (e.g. "People", "Pages")
  icon?: string;          // Optional icon URL
  sublabel?: string;      // Optional secondary line (e.g. username, description)
};

type SearchResponse = {
  suggestions: Suggestion[];
  query: string;          // Echo back the query (helps detect stale responses)
};

Client State

type AutocompleteState = {
  query: string;           // Current input value
  suggestions: Suggestion[];
  activeIndex: number;     // -1 = no selection, 0+ = highlighted item
  status: 'idle' | 'loading' | 'success' | 'error';
  isOpen: boolean;
};

Cache

// In-memory Map<query, Suggestion[]>
// Populated on every successful fetch
// Key normalisation: trim + lowercase
const cache = new Map<string, Suggestion[]>();

I — Interface

Component Props

interface AutocompleteProps {
  /** Called on each keystroke; returns suggestions */
  onSearch: (query: string) => Promise<Suggestion[]>;
  /** Called when a suggestion is selected */
  onSelect: (suggestion: Suggestion) => void;
  /** Optional: custom render for each suggestion */
  renderSuggestion?: (suggestion: Suggestion, isActive: boolean) => React.ReactNode;
  placeholder?: string;
  debounceMs?: number;       // Default: 150
  maxSuggestions?: number;   // Default: 8
  minQueryLength?: number;   // Default: 1 (don't fetch on empty input)
  className?: string;
}

Hook Interface

function useAutocomplete(options: {
  onSearch: (query: string) => Promise<Suggestion[]>;
  debounceMs?: number;
  maxSuggestions?: number;
  minQueryLength?: number;
}): {
  query: string;
  suggestions: Suggestion[];
  activeIndex: number;
  status: 'idle' | 'loading' | 'success' | 'error';
  isOpen: boolean;
  // Handlers to spread onto elements
  getInputProps: () => React.InputHTMLAttributes<HTMLInputElement>;
  getListProps: () => React.HTMLAttributes<HTMLUListElement>;
  getItemProps: (index: number) => React.HTMLAttributes<HTMLLIElement>;
}

The prop-getter pattern (getInputProps, getListProps, getItemProps) is borrowed from Downshift — it keeps ARIA wiring, keyboard handlers, and click handlers co-located and ensures nothing gets accidentally forgotten.

Search API

GET /api/search?q={query}&limit={n}

Headers:
  Accept: application/json

Response 200:
  { "suggestions": [...], "query": "react" }

Response 500:
  { "error": "Search service unavailable" }

O — Optimisations

This is the section that separates junior from senior answers. Don't skip it.

1. Debouncing

Without debouncing, typing "react" fires 5 API calls. With 150ms debounce, you fire 1.

const debouncedSearch = useMemo(
  () => debounce(async (q: string) => {
    const results = await onSearch(q);
    setSuggestions(results);
    setStatus('success');
  }, debounceMs),
  [onSearch, debounceMs]
);

Why 150ms? Below ~100ms feels instant; above ~300ms feels laggy. 150ms gives breathing room for typing without perceived delay.

2. Request Cancellation

Rapid typing can cause out-of-order responses — a slow request for "re" arrives after the fast request for "react", clobbering results.

useEffect(() => {
  const controller = new AbortController();

  if (query.length >= minQueryLength) {
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(r => r.json())
      .then(data => setSuggestions(data.suggestions))
      .catch(err => {
        if (err.name !== 'AbortError') setStatus('error');
      });
  }

  return () => controller.abort(); // Cancels on next render
}, [query]);

3. Client-Side Caching

Typing "rea", deleting back to "re", then retyping "rea" would fire the same request three times. Cache the results:

const cache = useRef(new Map<string, Suggestion[]>());

async function search(q: string) {
  const key = q.trim().toLowerCase();
  if (cache.current.has(key)) {
    setSuggestions(cache.current.get(key)!);
    return;
  }
  const results = await fetchSuggestions(q);
  cache.current.set(key, results);
  setSuggestions(results);
}

Cap the cache size (e.g. 100 entries) to prevent unbounded memory growth in long sessions.

4. Accessibility (ARIA)

This is non-negotiable for a complete answer. The ARIA combobox pattern:

// Input
<input
  role="combobox"
  aria-expanded={isOpen}
  aria-haspopup="listbox"
  aria-autocomplete="list"
  aria-controls="autocomplete-list"
  aria-activedescendant={
    activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
  }
/>

// Dropdown
<ul
  id="autocomplete-list"
  role="listbox"
  aria-label="Search suggestions"
>
  {suggestions.map((s, i) => (
    <li
      key={s.id}
      id={`suggestion-${i}`}
      role="option"
      aria-selected={i === activeIndex}
    >
      {s.label}
    </li>
  ))}
</ul>

Also: announce result count when suggestions load — "8 results available" via a live region.

5. Keyboard Navigation

KeyBehaviour
Move activeIndex down (wraps to 0 from bottom)
Move activeIndex up (wraps to last from top)
EnterSelect activeIndex item (or submit form if no selection)
EscapeClose dropdown, return focus to input
TabClose dropdown, move focus naturally
HomeMove to first suggestion
EndMove to last suggestion

6. Mobile Considerations

  • Minimum touch target size: 44×44px per WCAG 2.1
  • Suggestions should be large enough to tap easily
  • On iOS, input[type="search"] gets special styling — consider type="text" with a search icon if you need consistency
  • Virtual keyboard appearing shifts the layout — ensure the dropdown doesn't get hidden behind it (use position: fixed or scroll management)

7. Performance for Large Lists

If suggestions can be many (e.g. 50+), consider windowing — only render visible items. Libraries like react-window or react-virtual handle this. For typical autocompletes (≤10 items), it's overkill.

8. Empty and Error States

{status === 'loading' && <Spinner />}
{status === 'error' && <p>Something went wrong. Please try again.</p>}
{status === 'success' && suggestions.length === 0 && (
  <p>No results for "{query}"</p>
)}

Always render something — never leave the dropdown visually broken or silently empty.

The Full Implementation Sketch

function Autocomplete({ onSearch, onSelect, placeholder = 'Search...' }: AutocompleteProps) {
  const {
    query,
    suggestions,
    activeIndex,
    status,
    isOpen,
    getInputProps,
    getListProps,
    getItemProps,
  } = useAutocomplete({ onSearch });

  return (
    <div className="autocomplete">
      <input
        className="autocomplete__input"
        placeholder={placeholder}
        {...getInputProps()}
      />
      {isOpen && (
        <ul className="autocomplete__list" {...getListProps()}>
          {status === 'loading' && <li className="autocomplete__loading">Loading...</li>}
          {status === 'error' && <li className="autocomplete__error">Failed to load results</li>}
          {status === 'success' && suggestions.length === 0 && (
            <li className="autocomplete__empty">No results for "{query}"</li>
          )}
          {suggestions.map((s, i) => (
            <li
              key={s.id}
              className={`autocomplete__item ${i === activeIndex ? 'autocomplete__item--active' : ''}`}
              onClick={() => onSelect(s)}
              {...getItemProps(i)}
            >
              {s.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Summary

A complete autocomplete system design answer covers:

  • Requirements: keyboard + mouse nav, debounce, loading/error/empty states, a11y
  • Architecture: AutocompleteSearchInput + SuggestionDropdown, logic in useAutocomplete hook
  • Data model: Suggestion type from API, client state (query, suggestions, activeIndex, status), in-memory cache
  • Interface: prop-getter pattern for ARIA wiring, clean onSearch/onSelect callbacks, REST endpoint
  • Optimisations: debounce (150ms), AbortController, client cache, ARIA combobox, keyboard nav, mobile touch targets, empty/error states

The question isn't testing whether you know every detail — it's testing whether you can think systematically about a real product problem.

The Series: Frontend System Design Interview Questions