DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. Frontend Engineering
  4. System Design #6: Design a Typeahead / Autocomplete Widget
XLinkedInReddit
MediumFrontend Engineering

System Design #6: Design a Typeahead / Autocomplete Widget

D
DevPrep Team
8 min readยท0
Table of Contents
  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Approach 1: Naive Fetch-on-Keystroke
  • Approach 2: Debounced Fetch
  • Approach 3: Debounce + Request Deduplication + Client Cache โœ…
  • Why 150ms debounce?
  • Component Tree
  • D โ€” Data Model
  • Client-Side State
  • LRU Cache Design
  • I โ€” Interface Definition
  • API Contract
  • The Stale Response Problem
  • ARIA Combobox Pattern
  • O โ€” Optimizations
  • 1. Prefix-Based Cache Warming
  • 2. Highlight Matching with Fuzzy Support
  • 3. Keyboard Navigation State Machine
  • 4. Mobile-First: Full-Screen Takeover
  • 5. Analytics & Search Intelligence
  • 6. Rate Limiting & Graceful Degradation
  • 7. Server-Side: Trie + Ranking
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ
  • Summary Comparison Table

Hey folks, Rahul here ๐Ÿ‘‹

You know that search box on Google, Amazon, or YouTube where suggestions magically appear as you type? That's a typeahead (or autocomplete) widget โ€” and it's deceptively hard to build well.

I've seen candidates nail the basic debounce-and-fetch approach, then completely fall apart when asked about "What happens when the user types faster than the network?" or "How do you handle 10 billion possible suggestions?" Let's make sure that doesn't happen to you.

R โ€” Requirements

Functional Requirements

  • Display suggestions as the user types in a search input
  • Highlight matched portions of each suggestion
  • Support keyboard navigation (โ†‘/โ†“/Enter/Escape)
  • Show recent searches for authenticated users
  • Support multi-section results (products, categories, articles)
  • Handle selection โ†’ navigate to result or fill input

Non-Functional Requirements

  • Latency: Suggestions must appear within 100ms of the last keystroke (perceived)
  • Network efficiency: Minimize redundant API calls
  • Accessibility: Full ARIA combobox pattern with screen reader support
  • Scalability: Handle suggestion pools of billions of entries
  • Resilience: Graceful degradation on network failures

A โ€” Architecture

Let me walk you through the component architecture. There are three approaches candidates typically consider:

Approach 1: Naive Fetch-on-Keystroke

Fire an API call on every keystroke. Simple, but generates ~10 requests for "javascript" โ€” most of which are wasted. โŒ Don't do this.

Approach 2: Debounced Fetch

Classic approach: wait N ms after the last keystroke, then fetch. Reduces calls dramatically but introduces perceived latency โ€” the user finishes typing and waits 200-300ms before seeing anything.

Approach 3: Debounce + Request Deduplication + Client Cache โœ…

This is the production pattern. Combine debouncing with an LRU cache and request deduplication (in-flight tracking). The cache means repeated prefixes are instant. Let me show you:

class TypeaheadController {
  private cache = new LRUCache<string, Suggestion[]>(100);
  private inflight = new Map<string, AbortController>();
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
  
  async getSuggestions(query: string): Promise<Suggestion[]> {
    // 1. Minimum query length
    if (query.length < 2) return [];
    
    // 2. Check cache first
    const cached = this.cache.get(query);
    if (cached) return cached;
    
    // 3. Cancel previous in-flight request
    const existing = this.inflight.get('current');
    if (existing) existing.abort();
    
    // 4. Debounce
    return new Promise((resolve) => {
      if (this.debounceTimer) clearTimeout(this.debounceTimer);
      
      this.debounceTimer = setTimeout(async () => {
        const controller = new AbortController();
        this.inflight.set('current', controller);
        
        try {
          const results = await fetch(
            `/api/suggest?q=${encodeURIComponent(query)}`,
            { signal: controller.signal }
          ).then(r => r.json());
          
          this.cache.set(query, results);
          this.inflight.delete('current');
          resolve(results);
        } catch (e) {
          if (e.name !== 'AbortError') resolve([]);
        }
      }, 150); // 150ms sweet spot
    });
  }
}

Why 150ms debounce?

Research shows the average inter-keystroke interval for proficient typists is ~100-150ms. Setting the debounce at 150ms means we fire after most rapid typing bursts while keeping perceived latency low. Google actually uses ~100ms โ€” they can afford it with their edge infrastructure.

Component Tree

SearchContainer
โ”œโ”€โ”€ SearchInput          // Controlled input with ARIA attributes
โ”œโ”€โ”€ SuggestionDropdown   // Portal-rendered overlay
โ”‚   โ”œโ”€โ”€ SectionHeader    // "Products", "Categories", etc.
โ”‚   โ”œโ”€โ”€ SuggestionItem   // Individual result with highlight
โ”‚   โ””โ”€โ”€ RecentSearches   // Shown when input is empty + focused
โ””โ”€โ”€ SearchOverlay        // Mobile: full-screen takeover

D โ€” Data Model

Client-Side State

interface TypeaheadState {
  query: string;                    // Current input value
  suggestions: SuggestionSection[]; // Grouped results
  activeIndex: number;              // Keyboard navigation cursor
  isOpen: boolean;                  // Dropdown visibility
  isLoading: boolean;               // Show skeleton/spinner
  error: string | null;             // Network error state
  recentSearches: string[];         // From localStorage
}

interface SuggestionSection {
  id: string;
  title: string;           // "Products", "Categories"
  items: SuggestionItem[];
}

interface SuggestionItem {
  id: string;
  text: string;            // Display text
  highlight: TextRange[];  // Which characters to bold
  icon?: string;
  metadata?: string;       // "in Electronics", "243 results"
  url: string;             // Navigation target
}

interface TextRange {
  start: number;
  length: number;
}

LRU Cache Design

class LRUCache<K, V> {
  private map = new Map<K, V>();
  
  constructor(private maxSize: number) {}
  
  get(key: K): V | undefined {
    const value = this.map.get(key);
    if (value !== undefined) {
      // Move to end (most recent)
      this.map.delete(key);
      this.map.set(key, value);
    }
    return value;
  }
  
  set(key: K, value: V): void {
    if (this.map.has(key)) this.map.delete(key);
    this.map.set(key, value);
    
    if (this.map.size > this.maxSize) {
      // Delete oldest (first entry)
      const firstKey = this.map.keys().next().value;
      this.map.delete(firstKey);
    }
  }
}

Why LRU and not just a plain object? Memory. If a user explores many queries, an unbounded cache grows forever. LRU with 100 entries keeps memory ~50KB while caching the most relevant prefixes.

I โ€” Interface Definition

API Contract

// GET /api/suggest?q={query}&limit={limit}ยงions={sections}
interface SuggestRequest {
  q: string;        // Query string, min 2 chars
  limit?: number;   // Max results per section (default: 5)
  sections?: string; // Comma-separated: "products,categories,articles"
}

interface SuggestResponse {
  query: string;           // Echo back for stale-check
  sections: {
    id: string;
    title: string;
    items: {
      id: string;
      text: string;
      highlights: [number, number][]; // [start, length] pairs
      metadata?: string;
      url: string;
    }[];
  }[];
  queryId: string;  // For analytics tracking
}

The Stale Response Problem

This is a classic gotcha. User types "rea" โ†’ fires request โ†’ types "react" โ†’ fires another. If "rea" response arrives after "react" response, you'd show wrong suggestions. Solution:

// Track the query that triggered the latest request
let latestQuery = '';

async function fetchSuggestions(query: string) {
  latestQuery = query;
  const results = await api.suggest(query);
  
  // Only update UI if this is still the latest query
  if (query === latestQuery) {
    setSuggestions(results);
  }
  // Otherwise, silently discard โ€” a newer request owns the UI
}

Alternatively, the response echoes back the query field, so you can compare without closure tricks.

ARIA Combobox Pattern

<div role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
  <input
    role="searchbox"
    aria-autocomplete="list"
    aria-controls="suggestion-listbox"
    aria-activedescendant={activeId}  // Points to highlighted item
    value={query}
    onChange={handleChange}
    onKeyDown={handleKeyboard}
  />
  
  {isOpen && (
    <ul id="suggestion-listbox" role="listbox" aria-label="Search suggestions">
      {suggestions.map((item, i) => (
        <li
          key={item.id}
          id={`suggestion-${item.id}`}
          role="option"
          aria-selected={i === activeIndex}
          onClick={() => selectSuggestion(item)}
        >
          <HighlightedText text={item.text} ranges={item.highlight} />
        </li>
      ))}
    </ul>
  )}
</div>

The aria-activedescendant pattern is crucial โ€” it tells screen readers which suggestion is "focused" without actually moving DOM focus out of the input. This lets the user keep typing while navigating suggestions.

O โ€” Optimizations

1. Prefix-Based Cache Warming

Here's a trick Google uses: if you have cached results for "reac", you can use them as provisional results for "react" while the network request is in-flight:

function getProvisionalResults(query: string): Suggestion[] | null {
  // Walk backwards through prefixes
  for (let i = query.length - 1; i >= 2; i--) {
    const prefix = query.substring(0, i);
    const cached = cache.get(prefix);
    if (cached) {
      // Client-side filter: only show items that match the full query
      return cached.filter(item => 
        item.text.toLowerCase().includes(query.toLowerCase())
      );
    }
  }
  return null;
}

// Usage in fetch flow
async function handleQueryChange(query: string) {
  // Show provisional results immediately
  const provisional = getProvisionalResults(query);
  if (provisional) setSuggestions(provisional);
  
  // Then fetch fresh results
  const fresh = await controller.getSuggestions(query);
  setSuggestions(fresh);
}

This makes the UI feel instant. The user sees filtered results from cache while the real results load in the background.

2. Highlight Matching with Fuzzy Support

function HighlightedText({ text, query }: { text: string; query: string }) {
  if (!query) return <span>{text}</span>;
  
  const regex = new RegExp(
    `(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`,
    'gi'
  );
  const parts = text.split(regex);
  
  return (
    <span>
      {parts.map((part, i) =>
        regex.test(part)
          ? <mark key={i} className="bg-primary/20 text-foreground font-semibold">{part}</mark>
          : <span key={i}>{part}</span>
      )}
    </span>
  );
}

3. Keyboard Navigation State Machine

function handleKeyDown(e: KeyboardEvent) {
  const totalItems = suggestions.flatMap(s => s.items).length;
  
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      setActiveIndex(prev => 
        prev < totalItems - 1 ? prev + 1 : 0  // Wrap around
      );
      break;
      
    case 'ArrowUp':
      e.preventDefault();
      setActiveIndex(prev => 
        prev > 0 ? prev - 1 : totalItems - 1  // Wrap around
      );
      break;
      
    case 'Enter':
      if (activeIndex >= 0) {
        e.preventDefault();
        const item = getFlattenedItem(activeIndex);
        selectSuggestion(item);
      }
      break;
      
    case 'Escape':
      setIsOpen(false);
      inputRef.current?.blur();
      break;
      
    case 'Tab':
      setIsOpen(false); // Close on tab-away, don't prevent default
      break;
  }
}

4. Mobile-First: Full-Screen Takeover

On mobile, the dropdown pattern breaks โ€” virtual keyboards eat half the screen. The production pattern is a full-screen search overlay:

function SearchContainer() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  
  if (isMobile && isOpen) {
    return (
      <div className="fixed inset-0 z-50 bg-background">
        <div className="flex items-center gap-2 p-3 border-b">
          <Button variant="ghost" size="icon" onClick={close}>
            <ArrowLeft />
          </Button>
          <SearchInput autoFocus />
        </div>
        <ScrollArea className="h-[calc(100vh-60px)]">
          <SuggestionList />
        </ScrollArea>
      </div>
    );
  }
  
  // Desktop: standard dropdown
  return (
    <Popover open={isOpen}>
      <PopoverTrigger><SearchInput /></PopoverTrigger>
      <PopoverContent><SuggestionList /></PopoverContent>
    </Popover>
  );
}

5. Analytics & Search Intelligence

interface SearchAnalyticsEvent {
  queryId: string;
  query: string;
  resultCount: number;
  selectedIndex: number | null;   // null = no selection (user pressed Enter)
  selectedId: string | null;
  timeToSelection: number;        // ms from first keystroke to click
  suggestionsShown: string[];     // For relevance training
  isFromCache: boolean;
}

// Track "no result" queries for content gap analysis
function trackZeroResults(query: string) {
  analytics.track('search_zero_results', {
    query,
    timestamp: Date.now(),
    // This feeds into your content team's backlog
  });
}

The selectedIndex is gold for ranking โ€” if users consistently pick the 3rd suggestion, your ranking model needs tuning.

6. Rate Limiting & Graceful Degradation

class ResilientFetcher {
  private consecutiveErrors = 0;
  private backoffMs = 0;
  
  async fetch(query: string): Promise<Suggestion[]> {
    if (this.backoffMs > 0) {
      // During backoff, only serve from cache
      return this.cache.get(query) || [];
    }
    
    try {
      const results = await api.suggest(query);
      this.consecutiveErrors = 0;
      this.backoffMs = 0;
      return results;
    } catch (e) {
      this.consecutiveErrors++;
      // Exponential backoff: 1s, 2s, 4s, max 30s
      this.backoffMs = Math.min(
        1000 * Math.pow(2, this.consecutiveErrors - 1),
        30000
      );
      setTimeout(() => { this.backoffMs = 0; }, this.backoffMs);
      
      // Fall back to cache or empty
      return this.cache.get(query) || [];
    }
  }
}

7. Server-Side: Trie + Ranking

On the backend (brief overview for completeness), suggestions are typically served from a Trie or prefix tree stored in Redis or a dedicated service like Elasticsearch's completion suggester:

// Conceptual server-side ranking
interface ScoredSuggestion {
  text: string;
  score: number; // Composite of:
  // - popularity (global search frequency)
  // - recency (trending boost)
  // - personalization (user's past searches)
  // - exact prefix boost (starts-with > contains)
}

// Top-K selection using a min-heap for efficiency
function getTopSuggestions(prefix: string, k: number): ScoredSuggestion[] {
  const candidates = trie.findByPrefix(prefix); // O(p + n)
  return minHeapTopK(candidates, k);             // O(n log k)
}

Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

  1. IME Composition: For CJK (Chinese/Japanese/Korean) input, don't trigger searches during compositionstart/compositionend โ€” the input is incomplete. Listen for compositionend before fetching.
  2. Dropdown Positioning: Use position: fixed + floating-ui to handle scroll containers and viewport edges. CSS absolute breaks inside overflow-hidden ancestors.
  3. Click Outside vs. Mousedown: Use onMouseDown on suggestion items, not onClick. Why? Because onBlur on the input fires before onClick on the item, closing the dropdown before the click registers.
  4. URL Encoding: Always encodeURIComponent the query. Users will paste emojis, special characters, and even SQL injection attempts into your search box.
  5. Flash of Empty State: When switching from cached results to loading fresh ones, don't clear the UI. Show stale results with a subtle loading indicator until fresh data arrives.

Summary Comparison Table

AspectNaiveDebounce OnlyProduction (Cache + Dedup)
API calls for "javascript"102-31 (rest from cache)
Perceived latencyNetwork RTT per keyDebounce + RTT~0ms (cache) or Debounce + RTT
Stale responsesFrequentPossibleHandled via query check
Memory usageLowLowBounded (LRU)
Offline supportNoneNoneCache serves stale results

Next up: #7: Design a Calendar/Date Picker โ€” where we'll tackle date math nightmares, timezone handling, range selection state machines, and why Date is the worst API in JavaScript. Stay tuned! ๐Ÿ—“๏ธ

Related Articles

MediumFrontend Engineering

System Design #12: Design a Multi-Step Form Wizard

7 min read
MediumFrontend Engineering

Mastering Senior-Level JavaScript Interview Concepts

2 min read
MediumFrontend Engineering

System Design #9: Design a Collaborative Text Editor

9 min read

Comments (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Approach 1: Naive Fetch-on-Keystroke
  • Approach 2: Debounced Fetch
  • Approach 3: Debounce + Request Deduplication + Client Cache โœ…
  • Why 150ms debounce?
  • Component Tree
  • D โ€” Data Model
  • Client-Side State
  • LRU Cache Design
  • I โ€” Interface Definition
  • API Contract
  • The Stale Response Problem
  • ARIA Combobox Pattern
  • O โ€” Optimizations
  • 1. Prefix-Based Cache Warming
  • 2. Highlight Matching with Fuzzy Support
  • 3. Keyboard Navigation State Machine
  • 4. Mobile-First: Full-Screen Takeover
  • 5. Analytics & Search Intelligence
  • 6. Rate Limiting & Graceful Degradation
  • 7. Server-Side: Trie + Ranking
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ
  • Summary Comparison Table

Series

View all Frontend Engineering articles โ†’

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

ยฉ 2026 DevPrep. All rights reserved.