Design an autocomplete search
Lead a frontend system design interview for an autocomplete search. Cover requirements, state ownership, data fetching, rendering, accessibility, performance, testing, and rollout.
Answer Strategy
Autocomplete is a frontend system design question because the hard part is not the input box. The hard part is coordinating user intent, URL or local input state, network requests, stale responses, cached suggestions, keyboard accessibility, loading feedback, and product ranking constraints without making the UI feel random.
Start by separating four owners: the input owns what the user is typing, the query layer owns request cancellation and cache, the listbox owns active option and keyboard movement, and the product API owns ranking, permissions, and abuse controls. Once those boundaries are clear, the component becomes a composition problem instead of a tangle of effects.
A senior answer should volunteer the dangerous cases: out-of-order responses, IME composition, empty queries, slow networks, mobile keyboards, screen reader announcements, rate limiting, and analytics that must not leak private input. The code below is small enough for an interview but real enough to ship as the first version of the client boundary.
Reference Implementation: Query Hook + Accessible Combobox
This code demonstrates cancellation, stale-response protection, minimum query length, small in-memory caching, and ARIA combobox wiring. In a real app, the fetcher would call your API route or typed client.
type Suggestion = {
id: string;
label: string;
description?: string;
};
type AutocompleteState =
| { tag: 'idle'; suggestions: Suggestion[] }
| { tag: 'loading'; query: string; suggestions: Suggestion[] }
| { tag: 'ready'; query: string; suggestions: Suggestion[] }
| { tag: 'error'; query: string; message: string; suggestions: Suggestion[] };
const cache = new Map<string, Suggestion[]>();
function normalizeQuery(value: string) {
return value.trim().replace(/\s+/g, ' ').toLowerCase();
}
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
function useAutocompleteQuery(rawQuery: string) {
const query = useDebouncedValue(normalizeQuery(rawQuery), 180);
const [state, setState] = React.useState<AutocompleteState>({
tag: 'idle',
suggestions: [],
});
React.useEffect(() => {
if (query.length < 2) {
setState({ tag: 'idle', suggestions: [] });
return;
}
const cached= cache.get(query);
if (cached) {
setState({ tag: 'ready', query, suggestions: cached });
return;
}
const controller= new AbortController();
setState((previous)=> ({
tag: 'loading',
query,
suggestions: 'suggestions' in previous ? previous.suggestions : [],
}));
fetch('/api/search/suggestions?q=' + encodeURIComponent(query), {
signal: controller.signal,
})
.then((response)=> {
if (!response.ok) throw new Error('Suggestion request failed');
return response.json() as Promise<{ suggestions: Suggestion[] }>;
})
.then((payload) => {
if (controller.signal.aborted) return;
cache.set(query, payload.suggestions);
setState({ tag: 'ready', query, suggestions: payload.suggestions });
})
.catch((error) => {
if (controller.signal.aborted) return;
setState((previous) => ({
tag: 'error',
query,
message: error instanceof Error ? error.message : 'Unknown error',
suggestions: 'suggestions' in previous ? previous.suggestions : [],
}));
});
return () => controller.abort();
}, [query]);
return state;
}
function AutocompleteBox() {
const [input, setInput] = React.useState('');
const [activeIndex, setActiveIndex] = React.useState(-1);
const state = useAutocompleteQuery(input);
const suggestions = state.suggestions;
const listboxId = React.useId();
function commit(index: number) {
const selected = suggestions[index];
if (!selected) return;
setInput(selected.label);
setActiveIndex(-1);
}
return (
<div className="searchField">
<label htmlFor="search">Search</label>
<input
id="search"
role="combobox"
aria-autocomplete="list"
aria-expanded={suggestions.length > 0}
aria-controls={listboxId}
aria-activedescendant={
activeIndex >= 0 ? listboxId + '-option-' + activeIndex : undefined
}
value={input}
onChange={(event) => {
setInput(event.target.value);
setActiveIndex(-1);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setActiveIndex((index) => Math.min(index + 1, suggestions.length - 1));
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setActiveIndex((index) => Math.max(index - 1, 0));
}
if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
commit(activeIndex);
}
if (event.key === 'Escape') setActiveIndex(-1);
}}
/>
{state.tag === 'loading' && <p role="status">Loading suggestions...</p>}
{state.tag === 'error' && <p role="alert">{state.message}</p>}
{suggestions.length > 0 && (
<ul id={listboxId} role="listbox">
{suggestions.map((item, index) => (
<li
id={listboxId + '-option-' + index}
key={item.id}
role="option"
aria-selected={index= activeIndex}
onMouseDown={(event)=> event.preventDefault()}
onClick={()=> commit(index)}
>
<strong>{item.label}</strong>
{item.description && <span>{item.description}</span>}
</li>
))}
</ul>
)}
</div>
);
}Runnable Playground
Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.
type Suggestion = {
id: string;
label: string;
score: number;
};
function getVisibleSuggestions(
query: string,
suggestions: Suggestion[],
limit: number
): Suggestion[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return [];
return suggestions
.filter((suggestion) => suggestion.label.toLowerCase().includes(normalized))
.sort((a, b) => b.score - a.score || a.label.localeCompare(b.label))
.slice(0, Math.max(0, limit));
}Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('cancels stale requests and commits keyboard selection', async () => {
const user = userEvent.setup();
const aborts: string[] = [];
vi.stubGlobal('fetch', vi.fn((url: string, init?: RequestInit) => {
init?.signal?.addEventListener('abort', () => aborts.push(url));
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
suggestions: [{ id: '1', label: 'React', description: 'Library' }],
}),
} as Response);
}));
render(<AutocompleteBox />);
await user.type(screen.getByRole('combobox', { name: /search/i }), 're');
await waitFor(() => expect(screen.getByText('React')).toBeInTheDocument());
await user.keyboard('{ArrowDown}{Enter}');
expect(screen.getByRole('combobox', { name: /search/i })).toHaveValue('React');
});Interviewer Signal
Shows whether you can turn a broad product surface into a durable frontend architecture with clear contracts.
Constraints
- Spend the first five minutes on requirements and non-goals.
- Name client, server, cache, and URL state separately.
- Include accessibility, performance, and observability before the end.
Model Answer Shape
- Clarify users, scale, latency, collaboration, offline, and device constraints.
- Draw the route/component/data-flow shape before diving into component props.
- Choose explicit boundaries for API clients, cache, local state, design-system primitives, and tests.
Tradeoffs
- Generic primitives increase reuse but require stronger documentation and ownership.
- Client-side richness improves speed after load but can raise hydration and bundle costs.
- Real-time updates help freshness but complicate ordering, backpressure, and recovery.
Edge Cases
- Slow network and partial data.
- Permission changes while the user is on the page.
- Large datasets, long sessions, and stale caches.
Testing And Proof
- Contract tests for API adapters.
- Interaction tests for critical workflows.
- Performance budget and E2E scenario for the most important path.
Follow-Ups
- How would you roll this out safely to 1% of users?
- What would become a shared platform primitive after the second product adopted it?