Design a Search Autocomplete Component
Autocomplete (or typeahead search) is one of the most common frontend system design questions. While it looks simple on the surface, interviewers love it because it forces you to think about performance, network efficiency, accessibility, and clean state handling in a highly interactive component.
1. What the Interviewer Is Testing
When an interviewer asks you this question, they are checking if you know how to build a highly responsive UI while being gentle on the network and servers. Specifically, they evaluate:
- Debouncing & API Management: Can you prevent firing an API call on every single keystroke? How do you keep from overloading backend databases when millions of users are typing at the same time?
- UI State Handling: Does the dropdown handle loading, empty, and error states gracefully?
- Accessibility (a11y): Can someone use the autocomplete with just a keyboard? Are screen reader users announced to when suggestions load?
- Caching Recent/Repeated Queries: If a user backspaces or types a word they searched for 5 seconds ago, does the app hit the server again, or does it load instantly from a local cache?
- Performance at Scale: How do you render hundreds of results without freezing the browser?
2. How to Open Your Answer (The First 2 Minutes)
Do not start coding or drawing immediately. Start by asking clarifying questions to establish scope. This shows you think like a lead engineer.
Say something like:
"Before I jump in, I want to clarify how this autocomplete will be used. Is this a global search box (like on Amazon or Google) where we fetch millions of indexed items, or is it a scoped search (like filtering a list of 50 members in a team)? I'll assume it's a global search where results are fetched dynamically over the network.
Also, is the search real-time as they type, or does it only search on submit? I'll design it to fetch suggestions in real-time as the user types.
Finally, should we design this with mobile viewport constraints in mind (like virtual keyboards and touch interactions) or focus on web? I will start with a web-first architecture and then consider mobile touch targets and viewport constraints."
3. The 6-Step Answer Framework
Once you align on the scope, walk through the 6-step design framework systematically to demonstrate your architectural depth.
Step 1: Requirements Gathering (Functional & Non-Functional)
Start by outlining the constraints:
- Functional: User types in an input field. Suggestions appear in a dropdown list. The user can select a suggestion to search or complete the text. We support mouse and keyboard selections.
- Non-Functional:
- Latency: Suggestions must appear within 100ms of the user pausing typing.
- Scale: Must handle high-frequency typing without hitting the server on every keystroke.
- Accessibility: Works for screen readers and keyboard-only users.
- Offline: Fall back gracefully if the user's connection drops.
Step 2: Component & UI Architecture
Decompose the autocomplete into clean UI layers:
- Input Wrapper: An standard HTML
<input type="text">accompanied by a search icon and a clear button. - Dropdown Container: A floating container that positions itself underneath the input.
- List Items: The suggestion rows. They must support hover states, active keyboard highlights, and custom layouts (like bolding the matching letters).
- State Model:
query: The string currently in the input field.suggestions: Array of matching items retrieved from the API or cache.isLoading: Flag to render the spinner.highlightedIndex: Pointer tracking active keyboard focus in the list.
Step 3: State Management & Caching
Determine where suggestions live and how to store them:
- Local UI State: Kept inside the component (like React's
useStatefor active index). - In-Memory Client Cache: A simple object lookup store (
Record<string, string[]>) that remembers past queries. If the user typesreact, we fetch and cache the result. If they delete characters and then re-typereact, we load suggestions instantly from the cache.
Step 4: Data Fetching & API Contract
Establish a clean network contract:
- Protocol: REST over HTTP/2. (We don't need WebSockets since search queries are short-lived, request-response queries).
- API Schema:
- Request:
GET /api/search?q={query}&limit=10 - Response:
{ "suggestions": [ { "id": "1", "text": "javascript event loop" }, { "id": "2", "text": "javascript array methods" } ] }
- Request:
Step 5: Performance Considerations
Optimize the timing and rendering paths:
- Debouncing: We apply a 300ms delay. We wait for the user to pause typing for 300ms before we actually trigger the API call. 300ms is the sweet spot: it is fast enough that it feels instantaneous to humans, but slow enough to filter out fast keystrokes.
- DOM Limits: Limit suggestions to 10 items. Rendering 100 items inside a floating dropdown causes layout thrashing and slows down browser rendering pipelines.
Step 6: Accessibility & Edge Cases
Ensure the component is robust and accessible:
- Keyboard Controls:
ArrowDown/ArrowUpto change the focused item index.Enterto confirm selection.Escapeto dismiss the dropdown.
- ARIA Attributes: Use standard markup:
role="combobox"andaria-expandedon the input.role="listbox"on the dropdown container.role="option"andaria-selectedon each list row.
- Race Conditions: Active network requests must be canceled if the user types another character before the previous call resolves.
4. What Candidates Miss
Many developers fail system design interviews because they overlook real-world edge cases:
- Not mentioning debounce at all: This is an instant red flag.
- Forgetting keyboard accessibility: Building a component that only works with mouse clicks ignores a large portion of users and accessibility standards.
- The Race Condition Bug: This is the most common pitfall. If a user types
a, a request starts. Then they typeb, and a second request starts. If requestatakes 1.5 seconds but requestbtakes 0.2 seconds, the suggestions forbwill render first. Then, a second later, requestaresolves and overwrites the dropdown with stale results.- Solution: We must cancel any active, pending search requests using
AbortControllerbefore we trigger a new search.
- Solution: We must cancel any active, pending search requests using
5. Code Implementation
Here is how you write a clean, human-like implementation of the debouncing utility and the search function that cancels stale requests.
A Simple Debounce Utility
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): (...args: Parameters<T>) => void {
let timerId: ReturnType<typeof setTimeout> | null = null;
return function (...args: Parameters<T>) {
if (timerId) {
clearTimeout(timerId);
}
timerId = setTimeout(() => {
fn(...args);
}, delay);
};
}The Race Condition Fix (Using AbortController)
We wrap our search function in a service or class that keeps track of the active request. When a new search starts, we abort the previous controller:
class SearchService {
private activeController: AbortController | null = null;
private cache: Record<string, string[]> = {};
async search(query: string): Promise<string[]> {
const trimmedQuery = query.trim().toLowerCase();
// 1. Return cached results instantly if available
if (this.cache[trimmedQuery]) {
return this.cache[trimmedQuery];
}
// 2. Abort any previous pending request
if (this.activeController) {
this.activeController.abort();
}
// 3. Create a new controller for the current request
this.activeController = new AbortController();
const { signal } = this.activeController;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(trimmedQuery)}`, { signal });
if (!response.ok) {
throw new Error("Search request failed");
}
const data = await response.json();
const results = data.suggestions || [];
// Save to local cache
this.cache[trimmedQuery] = results;
return results;
} catch (error: any) {
// Ignore abort errors - they are expected when typing fast!
if (error.name === "AbortError") {
return [];
}
console.error("Search error:", error);
throw error;
} finally {
// Clear references when done
if (this.activeController?.signal === signal) {
this.activeController = null;
}
}
}
}6. Summary & Key Takeaways
- Debounce is Critical: Always throttle or debounce keyboard inputs to protect your backend APIs.
- Race Conditions: Never assume API requests will resolve in the order they were sent. Always use
AbortController(or unique request IDs) to cancel stale requests. - Caching Saves Network Ticks: A simple client-side cache avoids duplicate lookups when users delete or re-type text.
- Never Ignore Accessibility: Set proper ARIA roles (
combobox,listbox,option) and ensure keyboard-only navigation is supported natively.
