JS Guide
HomeQuestionsTopicsCompaniesResources
BookmarksSearch

Built for developers preparing for JavaScript, React & TypeScript interviews.

ResourcesQuestionsSupport
HomeQuestionsSearchProgress
HomeQuestionssystem-design
Next

Learn the concept

Data Layer Architecture

system-design
mid
autocomplete

Design an autocomplete/typeahead search component

autocomplete
typeahead
search
debounce
caching
lru-cache
accessibility
aria
keyboard-navigation
virtualization
system-design
Quick Answer

An autocomplete component requires debounced API calls, client-side caching (LRU), keyboard navigation, ARIA combobox accessibility, and virtualization for long result lists to deliver a responsive, inclusive search experience.

Detailed Explanation

Autocomplete is the most frequently asked frontend system design question in interviews at Google, Amazon, and Meta. It tests your ability to balance performance, accessibility, and user experience in a single component.

Requirements Gathering

Before diving into implementation, clarify these requirements:

Functional Requirements:

  • Text input triggers search suggestions as the user types
  • Results appear in a dropdown below the input
  • Keyboard navigation (arrow keys, Enter to select, Escape to close)
  • Click or tap to select a result
  • Recent searches / search history (optional)
  • Highlight matching text in results

Non-Functional Requirements:

  • Latency under 100ms perceived response time
  • Support 10,000+ results without performance degradation
  • Accessible to screen readers (WCAG 2.2 AA)
  • Mobile-friendly with touch targets >= 44px

Architecture & Data Flow

The data flow follows this pattern:

  1. User types in the input field
  2. Debounce waits 300ms after the last keystroke before firing
  3. Cache check — if the query is cached and fresh, return immediately
  4. API call — GET /api/search?q={query}&limit=10
  5. Cache store — save the response in an LRU cache
  6. Render results — display in a dropdown, virtualized if the list is long
  7. User selects — via keyboard or mouse/touch

Debouncing Strategy

Why debounce? Without it, typing "react hooks" fires 11 API calls (one per keystroke). With a 300ms debounce, it fires 1-2 calls.

  • 300ms is the standard debounce delay — fast enough to feel responsive, slow enough to batch keystrokes
  • Cancel in-flight requests when a new query starts (AbortController)
  • Show a loading indicator only if the request takes > 200ms to avoid flickering

Client-Side Caching (LRU)

An LRU (Least Recently Used) cache stores recent query results to avoid redundant API calls:

  • Cache key: the normalized query string (lowercased, trimmed)
  • Cache size: 50-100 entries is typical
  • TTL: 5 minutes for most search data
  • When the user backspaces from "reac" to "rea", the cached result for "rea" loads instantly

API Design

GET /api/search?q=react&limit=10&category=docs

Response:

JSON
{
  "results": [
    { "id": "1", "title": "React Hooks", "category": "docs", "highlight": "<mark>React</mark> Hooks" },
    { "id": "2", "title": "React Router", "category": "library", "highlight": "<mark>React</mark> Router" }
  ],
  "total": 142,
  "queryTime": 12
}

Key decisions:

  • Server returns pre-highlighted matches (safer than client-side regex on user input)
  • limit parameter for pagination
  • Response includes total for "showing X of Y results" UI

Keyboard Navigation

| Key | Action | |-----|--------| | ArrowDown | Move focus to next result | | ArrowUp | Move focus to previous result | | Enter | Select the focused result | | Escape | Close the dropdown, clear focus | | Tab | Close dropdown, move focus to next form element |

Track the activeIndex in state. On arrow key press, update the index and scroll the active item into view.

Accessibility (ARIA Combobox Pattern)

The WAI-ARIA combobox pattern is the standard for autocomplete:

  • Input has role="combobox", aria-expanded, aria-autocomplete="list", aria-controls pointing to the listbox ID
  • Results list has role="listbox"
  • Each result has role="option" with a unique ID
  • aria-activedescendant on the input points to the currently focused option's ID
  • Announce result count to screen readers: "5 results available" using aria-live="polite"

Performance: Virtualization

For long result lists (100+ items), render only visible items using virtualization (react-window or @tanstack/virtual):

  • Only 10-15 DOM nodes exist at any time regardless of list length
  • Reduces memory usage and improves scroll performance
  • Essential on mobile devices with limited resources

Mobile Considerations

  • Touch targets minimum 44x44px for each result row
  • On mobile, consider a full-screen search overlay pattern
  • Debounce may need to be longer (400-500ms) on slower networks
  • Support inputmode="search" for the search keyboard on mobile
  • Dismiss dropdown when tapping outside (use a backdrop overlay)

Error Handling & Edge Cases

  • Empty query: Show recent searches or trending searches
  • No results: Show helpful message with suggestions
  • Network error: Show cached results if available, with a retry option
  • XSS: Never use dangerouslySetInnerHTML with user input for highlighting — use server-provided safe highlights or a sanitizer
  • Race conditions: AbortController cancels stale requests; only the latest response is rendered

Code Examples

Debounced search hook with LRU caching and AbortControllerTypeScript
import { useState, useRef, useCallback, useEffect } from 'react';

interface SearchResult {
  id: string;
  title: string;
  highlight: string;
}

// Simple LRU cache implementation
class LRUCache<T> {
  private cache = new Map<string, { data: T; timestamp: number }>();
  constructor(private maxSize = 50, private ttlMs = 5 * 60 * 1000) {}

  get(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    if (Date.now() - entry.timestamp > this.ttlMs) {
      this.cache.delete(key);
      return null;
    }
    // Move to end (most recently used)
    this.cache.delete(key);
    this.cache.set(key, entry);
    return entry.data;
  }

  set(key: string, data: T): void {
    this.cache.delete(key);
    if (this.cache.size >= this.maxSize) {
      // Delete the oldest (first) entry
      const firstKey = this.cache.keys().next().value;
      if (firstKey !== undefined) this.cache.delete(firstKey);
    }
    this.cache.set(key, { data, timestamp: Date.now() });
  }
}

const searchCache = new LRUCache<SearchResult[]>();

export function useDebouncedSearch(delay = 300) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const abortRef = useRef<AbortController | null>(null);
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  const search = useCallback((searchQuery: string) => {
    // Clear previous debounce timer
    clearTimeout(timerRef.current);

    const trimmed = searchQuery.trim().toLowerCase();
    if (!trimmed) {
      setResults([]);
      setIsLoading(false);
      return;
    }

    // Check cache first
    const cached = searchCache.get(trimmed);
    if (cached) {
      setResults(cached);
      setIsLoading(false);
      return;
    }

    setIsLoading(true);

    timerRef.current = setTimeout(async () => {
      // Cancel any in-flight request
      abortRef.current?.abort();
      abortRef.current = new AbortController();

      try {
        const res = await fetch(
          `/api/search?q=${encodeURIComponent(trimmed)}&limit=10`,
          { signal: abortRef.current.signal }
        );
        const data = await res.json();
        searchCache.set(trimmed, data.results);
        setResults(data.results);
      } catch (err) {
        if ((err as Error).name !== 'AbortError') {
          console.error('Search failed:', err);
          setResults([]);
        }
      } finally {
        setIsLoading(false);
      }
    }, delay);
  }, [delay]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      clearTimeout(timerRef.current);
      abortRef.current?.abort();
    };
  }, []);

  return { query, setQuery: (q: string) => { setQuery(q); search(q); }, results, isLoading };
}

Real-World Applications

Use Cases

E-commerce Product Search

Amazon, Shopify, and other e-commerce platforms use autocomplete to suggest products, categories, and brands as users type, reducing time to purchase and improving conversion rates

IDE Code Completion

VS Code and JetBrains IDEs use typeahead to suggest variable names, function signatures, and API methods, combining local analysis with language server protocol responses

Address Autocomplete

Google Maps and Mapbox provide address autocomplete components that reduce form abandonment by 20-30% compared to free-text address fields

Mini Projects

GitHub Repository Search

intermediate

Build an autocomplete that searches GitHub repositories via the public API with debouncing, caching, and keyboard navigation. Display repo name, stars, and language in results.

Multi-source Search with Categories

advanced

Create an autocomplete that searches across multiple categories (users, posts, tags) simultaneously, displaying grouped results with category headers and custom icons.

Industry Examples

Google

Google Search autocomplete predicts queries using historical search data, trending topics, and personalized suggestions, processing billions of queries with sub-100ms response times

Algolia

Provides search-as-a-type infrastructure with client-side InstantSearch libraries that handle debouncing, caching, and highlighting out of the box

Slack

Uses typeahead extensively for @mentions, channel search, and slash commands with fuzzy matching and contextual ranking

Resources

WAI-ARIA Combobox Pattern

docs

GreatFrontEnd - Autocomplete System Design

article

MDN - AbortController

docs

Related Questions

What is the difference between client state and server state?

junior
data-layer

What makes a good component API for a design system?

junior
component-architecture
Next
Design a real-time notification system for a web application
Next