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 takeoverD โ 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 ๐ฅ
- IME Composition: For CJK (Chinese/Japanese/Korean) input, don't trigger searches during
compositionstart/compositionendโ the input is incomplete. Listen forcompositionendbefore fetching. - Dropdown Positioning: Use
position: fixed+floating-uito handle scroll containers and viewport edges. CSSabsolutebreaks inside overflow-hidden ancestors. - Click Outside vs. Mousedown: Use
onMouseDownon suggestion items, notonClick. Why? BecauseonBluron the input fires beforeonClickon the item, closing the dropdown before the click registers. - URL Encoding: Always
encodeURIComponentthe query. Users will paste emojis, special characters, and even SQL injection attempts into your search box. - 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
| Aspect | Naive | Debounce Only | Production (Cache + Dedup) |
|---|---|---|---|
| API calls for "javascript" | 10 | 2-3 | 1 (rest from cache) |
| Perceived latency | Network RTT per key | Debounce + RTT | ~0ms (cache) or Debounce + RTT |
| Stale responses | Frequent | Possible | Handled via query check |
| Memory usage | Low | Low | Bounded (LRU) |
| Offline support | None | None | Cache 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! ๐๏ธ