
Autocomplete Frontend System Design
Interview Prompt
Design an Autocomplete UI component that allows users to enter a search term into a text box. As the user types, a list of search results appears in a popup. The user can navigate through the results and select one.
The component should be generic enough to be reused across different websites and product surfaces. Both the input field UI and the search result UI should be customizable.
Real-world examples include:
- Google Search suggestions.
- Facebook Search with rich results such as people, pages, groups, and posts.
- LinkedIn global search.
- E-commerce product search.
- Internal admin search tools.
Requirements
Functional Requirements
- Render a text input where users can type a search query.
- Show a popup list of matching results.
- Fetch results from a backend API based on the query.
- Allow users to select a result.
- Support keyboard navigation.
- Support mouse and touch interactions.
- Allow callers to customize:
- Input rendering.
- Result item rendering.
- Loading state.
- Empty state.
- Error state.
- Cache previous query results to avoid unnecessary network calls.
- Prevent race conditions when multiple requests are in flight.
- Support accessibility semantics for screen readers.
Non-Functional Requirements
- Work across desktop, tablet, and mobile devices.
- Be reusable across different products.
- Be performant under fast typing.
- Avoid unnecessary re-renders.
- Be resilient to API latency and failures.
- Be easy to test.
- Be easy to theme for different design systems.
Out of Scope for Initial Version
- Server-side fuzzy search implementation.
- Full offline search.
- Ranking algorithm design.
- Multi-select autocomplete.
- Complex natural-language query understanding.
These can be discussed as follow-up extensions.
Requirements Exploration
A staff-level answer should not jump directly into implementation. Start by clarifying the product and technical constraints.
What kind of results should be supported?
The safest design is to treat results as generic data. The autocomplete component should not assume every result is text-only.
Possible result types:
- Plain text suggestion.
- Entity result with image and subtitle.
- Product card.
- User profile.
- Group, page, document, or media result.
- Custom React-rendered result.
The component should expose a renderItem API so product teams can decide how each result is displayed.
What devices should be supported?
The component should work on:
- Desktop browsers.
- Mobile browsers.
- Tablets.
- Touch devices.
- Keyboard-only environments.
- Screen readers.
Mobile support affects popup positioning, touch target size, keyboard behavior, and viewport resizing.
Do we need fuzzy search?
For the initial version, no. The backend API can own matching, ranking, typo tolerance, and personalization. The frontend should focus on query handling, caching, rendering, and interaction.
What should happen for an empty query?
Options:
- Show nothing.
- Show recent searches.
- Show trending suggestions.
- Show default suggestions passed by the caller.
The component should support initialResults, but the product should choose the behavior.
How should selection work?
Selection can mean different things depending on the product:
- Fill the input with selected text.
- Navigate to a destination page.
- Submit a search.
- Add an entity to a form.
- Call a custom callback.
The component should expose onSelect(item) and let the consuming product decide.
High-Level Architecture
Autocomplete can be modeled as a small MVC-style system.
Components
1. Input Field UI
Responsibilities:
- Render the text input.
- Capture user typing.
- Forward query changes to the controller.
- Handle focus and blur behavior.
- Support keyboard events such as ArrowDown, ArrowUp, Enter, Escape, and Tab.
The input should be customizable because different products may need different styling, labels, icons, placeholders, or design system wrappers.
2. Results Popup UI
Responsibilities:
- Render the list of results.
- Render loading, empty, and error states.
- Support mouse, touch, and keyboard-highlighted states.
- Notify the controller when an item is selected.
- Position itself relative to the input.
The popup should not know how search works. It should only render the state it receives.
3. Cache
Responsibilities:
- Store results for previous queries.
- Reduce duplicate API calls.
- Support cache expiration.
- Optionally support LRU eviction to avoid unbounded memory growth.
The cache can start simple with an in-memory Map, then evolve into an LRU cache with TTL.
4. Controller
Responsibilities:
- Own the current query.
- Own the current result list.
- Own loading and error states.
- Debounce query changes.
- Check cache before fetching.
- Fetch results from the API on cache miss.
- Cancel or ignore stale requests.
- Notify UI components when state changes.
The controller is the brain of the component.
Data Model
Generic Result Type
The component should avoid hardcoding result shape. Instead, use generics.
type AutocompleteItem = unknown;
A real implementation should allow callers to provide a type:
interface UserResult {
id: string;
name: string;
title?: string;
avatarUrl?: string;
}
Component State
interface AutocompleteState<TItem> {
query: string;
results: TItem[];
highlightedIndex: number;
isOpen: boolean;
isLoading: boolean;
error: Error | null;
}
Cache Entry
interface CacheEntry<TItem> {
query: string;
results: TItem[];
createdAt: number;
expiresAt?: number;
}
Cache Interface
interface AutocompleteCache<TItem> {
get(query: string): TItem[] | undefined;
set(query: string, results: TItem[]): void;
delete(query: string): void;
clear(): void;
}
This keeps the cache implementation swappable. For example, one product may want simple memory cache while another may use React Query, SWR, IndexedDB, or a shared service-level cache.
Public Component API
A reusable autocomplete should expose behavior through props rather than forcing product-specific logic.
interface AutocompleteProps<TItem> {
value?: string;
defaultValue?: string;
placeholder?: string;
disabled?: boolean;
minQueryLength?: number;
debounceMs?: number;
cacheTtlMs?: number;
maxResults?: number;
fetchResults: (query: string, signal?: AbortSignal) => Promise<TItem[]>;
getItemKey: (item: TItem) => string;
getItemLabel: (item: TItem) => string;
onQueryChange?: (query: string) => void;
onSelect?: (item: TItem) => void;
onOpenChange?: (isOpen: boolean) => void;
renderInput?: (props: RenderInputProps) => React.ReactNode;
renderItem?: (item: TItem, state: RenderItemState) => React.ReactNode;
renderLoading?: () => React.ReactNode;
renderEmpty?: (query: string) => React.ReactNode;
renderError?: (error: Error) => React.ReactNode;
}
Why this API works well
fetchResultsmakes the component backend-agnostic.renderItemmakes the result UI product-specific.getItemKeyavoids unstable React keys.getItemLabelsupports default rendering and input population.debounceMs,minQueryLength, andcacheTtlMsallow product tuning.- Controlled and uncontrolled modes support different integration styles.
Request Lifecycle
When the user types:
- Input emits a query change.
- Controller updates local query state.
- Controller waits for debounce delay.
- If query length is below
minQueryLength, clear results or show initial results. - Check cache.
- If cache hit, show cached results immediately.
- If cache miss, send API request.
- Show loading state.
- When response returns:
- Ignore stale responses.
- Store results in cache.
- Render results in popup.
- If request fails:
- Show error state.
- Keep the input usable.
Handling Race Conditions
Autocomplete is vulnerable to out-of-order responses.
Example:
- User types
a. - Request for
astarts. - User quickly types
ap. - Request for
apstarts. - Request for
apreturns first. - Request for
areturns later and incorrectly overwrites the results.
Solution 1: AbortController
Cancel the previous request when a new request starts.
let abortController: AbortController | null = null;
async function runSearch(query: string) {
abortController?.abort();
const controller = new AbortController();
abortController = controller;
const results = await fetchResults(query, controller.signal);
return results;
}
Solution 2: Request ID
Track the latest request and ignore stale responses.
let latestRequestId = 0;
async function runSearch(query: string) {
const requestId = ++latestRequestId;
const results = await fetchResults(query);
if (requestId !== latestRequestId) {
return;
}
setResults(results);
}
Staff-Level Recommendation
Use both:
AbortControllerto reduce wasted network and backend work.- Request ID guard to protect against APIs or environments where cancellation is unreliable.
Caching Strategy
Simple Cache
A simple Map is enough for the first version.
const cache = new Map<string, CacheEntry<TItem>>();
Cache Key Normalization
Normalize query keys so equivalent queries reuse cache entries.
function normalizeQuery(query: string) {
return query.trim().toLowerCase();
}
TTL
Autocomplete data can become stale. Use a TTL.
function isExpired(entry: CacheEntry<unknown>) {
return entry.expiresAt != null && Date.now() > entry.expiresAt;
}
LRU Eviction
To avoid memory growth, cap cache size.
const maxCacheSize = 100;
When the cache exceeds the limit, remove the least recently used query.
Prefix Reuse Optimization
If the backend supports it or if results are stable enough, the component can reuse prefix results locally.
Example:
- Query
appreturnsapple,app store,application. - Query
applcan initially filter from cachedappresults while the network request is pending.
This improves perceived performance, but it must be used carefully because backend ranking may differ by exact query.
Debouncing and Throttling
Debouncing
Debouncing waits until the user pauses typing before sending a request.
Best for autocomplete because users often type multiple characters quickly.
Common values:
- 100ms for very fast internal APIs.
- 200ms to 300ms for typical search APIs.
- 400ms+ for expensive APIs.
Throttling
Throttling limits requests to at most once per time window. It can be useful for real-time streams, but debounce is usually better for autocomplete input.
Staff-Level Recommendation
Use debounce as the default and make it configurable.
debounceMs = 200;
Keyboard Interaction
A high-quality autocomplete must be keyboard accessible.
| Key | Behavior |
|---|---|
ArrowDown | Move highlight to next result. |
ArrowUp | Move highlight to previous result. |
Enter | Select highlighted result. |
Escape | Close popup. |
Tab | Move focus away naturally. |
Home | Optional: move to first result. |
End | Optional: move to last result. |
The highlighted item should stay visible if the popup is scrollable.
Accessibility
Use the WAI-ARIA combobox pattern.
Recommended Semantics
Input:
<input
role="combobox"
aria-expanded={isOpen}
aria-controls="autocomplete-listbox"
aria-autocomplete="list"
aria-activedescendant={highlightedItemId}
/>
Popup:
<ul id="autocomplete-listbox" role="listbox">
{results.map((item) => (
<li id={itemId} role="option" aria-selected={isHighlighted}>
...
</li>
))}
</ul>
Accessibility Requirements
- Input must have an accessible label.
- Popup state should be announced through ARIA attributes.
- Highlighted result should be exposed via
aria-activedescendant. - Result options should use
role="option". - Do not trap focus unnecessarily.
- Support keyboard-only users.
- Preserve native input behavior where possible.
- Loading and error states should be announced if important.
Popup Positioning
The popup should usually be positioned relative to the input.
Options
Inline Positioning
Render the popup below the input in normal DOM flow.
Pros:
- Simple.
- Easier styling.
- Easier accessibility.
Cons:
- Can be clipped by parent containers with
overflow: hidden. - Harder to handle viewport boundaries.
Portal Positioning
Render the popup into a portal, usually under document.body.
Pros:
- Avoids clipping.
- Easier to layer above modals and containers.
- Better for complex layouts.
Cons:
- More complex positioning logic.
- Requires scroll and resize listeners.
- Must carefully preserve accessibility relationships.
Staff-Level Recommendation
Start with inline rendering for the base component. Provide an escape hatch for portal rendering when used inside modals, drawers, tables, or scroll containers.
Mobile Considerations
On mobile:
- The virtual keyboard reduces available viewport height.
- Touch targets should be at least 44px high.
- Popup should not be hidden behind the keyboard.
- Scrolling inside popup should feel natural.
- Avoid hover-only interactions.
- Consider full-screen search mode for complex result UIs.
For consumer search experiences, a full-screen mobile autocomplete can be better than a small anchored dropdown.
Error Handling
Errors should not break the input.
Possible states:
- API unavailable.
- Network timeout.
- Request aborted.
- Unauthorized request.
- Rate limited.
- Malformed response.
Recommended behavior:
- Keep the typed query in the input.
- Show a lightweight error state in the popup.
- Allow retry on the next keystroke.
- Do not cache failed responses by default.
- Do not show errors for intentionally aborted requests.
Loading and Empty States
Loading State
Show loading when:
- Query is valid.
- Cache miss occurs.
- Network request is in flight.
Avoid flicker by adding a small delay before showing loading if the API is usually fast.
Empty State
Show empty state when:
- Query is valid.
- Request completed successfully.
- Results array is empty.
Example:
No results found for “{query}”.
Performance Considerations
Frontend Performance
- Debounce requests.
- Cache query results.
- Memoize expensive renderers.
- Virtualize long result lists.
- Avoid re-rendering the whole page on each keystroke.
- Use stable keys from
getItemKey. - Avoid layout thrashing when positioning popup.
- Lazy-load heavy result item assets.
Network Performance
- Abort stale requests.
- Limit max results.
- Use compressed responses.
- Avoid sending requests for very short queries.
- Use edge caching when possible.
- Consider prefetching popular suggestions.
Rendering Large Result Lists
Most autocomplete UIs should not show hundreds of results. A limit of 5 to 10 visible results is often better.
If a product requires many results, use virtualization.
Security and Privacy
Autocomplete can leak sensitive user intent because every keystroke may become a request.
Things to consider:
- Avoid logging raw sensitive queries unless necessary.
- Redact PII where appropriate.
- Respect user privacy settings.
- Use HTTPS.
- Avoid exposing unauthorized entities in suggestions.
- Backend must enforce authorization.
- Frontend should not rely on filtering unauthorized results client-side.
Backend API Contract
The frontend can work with a simple API.
Request
GET /api/search-suggestions?q=apple&limit=10
Response
interface SearchSuggestionsResponse<TItem> {
query: string;
results: TItem[];
nextCursor?: string;
}
Example Response
{
"query": "apple",
"results": [
{
"id": "1",
"type": "company",
"title": "Apple",
"subtitle": "Technology company",
"imageUrl": "https://example.com/apple.png"
}
]
}
Backend Responsibilities
The backend should own:
- Matching.
- Ranking.
- Authorization.
- Personalization.
- Rate limiting.
- Abuse prevention.
- Deduplication.
- Typo tolerance, if supported.
The frontend should not attempt to duplicate ranking logic.
React Hook Design
A good implementation separates behavior from rendering.
function useAutocomplete<TItem>(options: UseAutocompleteOptions<TItem>) {
return {
query,
results,
highlightedIndex,
isOpen,
isLoading,
error,
getInputProps,
getListboxProps,
getOptionProps,
setQuery,
open,
close,
selectItem,
};
}
Example: useSearch hook logic
The hook below shows practical request lifecycle handling for search:
- trim empty queries;
- debounce requests;
- cancel stale requests with
AbortController; - preserve a clean loading and error state model.
import { useEffect, useState } from 'react';
type SearchState<T> = {
data: T[];
loading: boolean;
error: string | null;
};
export function useSearch<T>(
query: string,
searchFn: (query: string, signal: AbortSignal) => Promise<T[]>,
delay = 300
): SearchState<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
setData([]);
setLoading(false);
setError(null);
return;
}
const controller = new AbortController();
const timerId = window.setTimeout(async () => {
setLoading(true);
setError(null);
try {
const results = await searchFn(trimmedQuery, controller.signal);
setData(results);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
setError(error instanceof Error ? error.message : 'Search failed');
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, delay);
return () => {
window.clearTimeout(timerId);
controller.abort();
};
}, [query, searchFn, delay]);
return { data, loading, error };
}
Why a Hook Helps
- Keeps logic reusable.
- Allows multiple visual implementations.
- Makes behavior easier to unit test.
- Enables design-system integration.
- Separates accessibility props from styling.
Example Usage
<Autocomplete<UserResult>
placeholder="Search people"
minQueryLength={2}
debounceMs={200}
fetchResults={searchUsers}
getItemKey={(user) => user.id}
getItemLabel={(user) => user.name}
onSelect={(user) => navigate(`/users/${user.id}`)}
renderItem={(user, state) => (
<div className={state.isHighlighted ? styles.highlighted : styles.item}>
<img src={user.avatarUrl} alt="" />
<div>
<div>{user.name}</div>
<div>{user.title}</div>
</div>
</div>
)}
/>
Testing Strategy
Unit Tests
Test controller behavior:
- Query updates.
- Debounce behavior.
- Cache hit and miss.
- Error handling.
- Request cancellation.
- Stale response handling.
- Highlight movement.
- Selection behavior.
Component Tests
Test user interactions:
- Typing opens popup.
- Results render correctly.
- Clicking a result calls
onSelect. - Arrow keys move highlight.
- Enter selects highlighted item.
- Escape closes popup.
- Empty state renders.
- Loading state renders.
- Error state renders.
Accessibility Tests
Test:
- Input has accessible label.
- Combobox attributes are present.
- Listbox and options have correct roles.
aria-activedescendantupdates correctly.- Keyboard-only flow works.
End-to-End Tests
Test real browser behavior:
- Desktop keyboard usage.
- Mobile viewport behavior.
- Popup positioning.
- Slow network.
- API failure.
- Selection navigation.
Tradeoffs
Controlled vs Uncontrolled Component
| Approach | Pros | Cons |
|---|---|---|
| Controlled | Caller has full state control. Easier integration with forms. | More boilerplate for consumers. |
| Uncontrolled | Easier to use. Good defaults. | Harder for caller to coordinate external state. |
Recommendation: support both.
Built-in Fetching vs Caller-Owned Fetching
| Approach | Pros | Cons |
|---|---|---|
| Built-in fetching | Simple consumer API. Consistent behavior. | Less flexible. Harder to integrate with React Query/SWR. |
| Caller-owned fetching | Maximum flexibility. | More work for each consumer. |
Recommendation: accept fetchResults as a prop, but keep request lifecycle management inside the component.
Inline Popup vs Portal Popup
| Approach | Pros | Cons |
|---|---|---|
| Inline | Simple and accessible. | Can be clipped by containers. |
| Portal | Handles complex layouts. | More complex positioning and focus handling. |
Recommendation: default to inline; support portal as an advanced option.
Cache in Component vs Shared Cache
| Approach | Pros | Cons |
|---|---|---|
| Component cache | Simple and isolated. | Duplicate cache across instances. |
| Shared cache | Better reuse across page. | More complexity and invalidation concerns. |
Recommendation: provide a default cache and allow injection of a custom cache.
Edge Cases
- User types quickly and responses return out of order.
- User clears input while request is in flight.
- User tabs away while popup is open.
- User clicks outside the component.
- API returns duplicate results.
- API returns result for a different query.
- Result item is removed between render and selection.
- Popup is near bottom of viewport.
- Parent container has
overflow: hidden. - Browser autofill interacts with input.
- Mobile keyboard changes viewport height.
- IME composition for Chinese, Japanese, or Korean input.
- Screen reader announces too much or too little.
- Result list is very long.
- Query contains leading/trailing spaces.
- User pastes a large string.
- Network goes offline.
- Backend rate limits requests.
Internationalization
Autocomplete should support global users.
Consider:
- Unicode input.
- Right-to-left languages.
- IME composition events.
- Locale-aware matching handled by backend.
- Translated loading, empty, and error states.
- Locale-specific ranking if needed.
Important: avoid firing requests during active IME composition. Wait until composition ends.
<input
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={(event) => {
setIsComposing(false);
setQuery(event.currentTarget.value);
}}
/>
Observability
For production systems, track component health and user experience.
Useful metrics:
- Query count.
- Cache hit rate.
- API latency.
- Error rate.
- Empty result rate.
- Selection rate.
- Time to first result.
- Request abort count.
- Keyboard vs mouse selection rate.
Useful logs:
- Request failure reason.
- Backend status code.
- Component configuration.
Be careful not to log raw sensitive queries unnecessarily.
Progressive Enhancement Roadmap
Version 1
- Text input.
- Popup result list.
- Debounced API fetch.
- Basic cache.
- Keyboard navigation.
- Custom result renderer.
- Basic accessibility.
Version 2
- TTL and LRU cache.
- Portal popup positioning.
- Virtualized results.
- Better mobile UI.
- Grouped results.
- Recent searches.
- Analytics hooks.
Version 3
- Multi-section results.
- Federated search across entity types.
- AI-assisted suggestions.
- Personalized ranking.
- Offline fallback.
- Streaming results.
Staff Engineer Framing
A strong staff-level answer should show that autocomplete is not just a dropdown. It is a reusable interaction system that sits at the boundary of frontend architecture, backend search, accessibility, performance, design systems, and product analytics.
Key principles:
- Separate behavior from presentation.
- Keep the component generic through type parameters and render props.
- Own request lifecycle carefully to prevent race conditions.
- Provide good defaults but allow escape hatches.
- Treat accessibility as a core requirement, not a polish task.
- Avoid pushing ranking, authorization, and personalization into the frontend.
- Design for mobile and international input from the beginning.
- Add observability so the team can measure quality in production.
Interview Answer Summary
I would design autocomplete as a reusable component composed of an input UI, result popup UI, cache, and controller. The input captures user intent, the controller debounces query changes, checks cache, fetches from the backend on cache miss, protects against stale responses, and pushes state into the popup. The popup renders customizable results and handles selection.
The component API should be generic and expose fetchResults, renderItem, getItemKey, getItemLabel, and event callbacks such as onSelect. Internally, I would support debouncing, cache TTL, request cancellation, keyboard navigation, ARIA combobox semantics, loading, empty, and error states.
For performance, I would debounce input, cache previous results, limit result size, abort stale requests, and optionally virtualize long lists. For accessibility, I would follow the ARIA combobox/listbox pattern and support keyboard-only usage. For extensibility, I would separate the behavior into a useAutocomplete hook and keep the visual rendering customizable through render props or slots.
Follow-Up Interview Questions
How do you prevent stale API responses from overwriting newer results?
Use AbortController to cancel previous requests and a request ID guard to ignore stale responses that still resolve.
How would you support rich results?
Keep the result item generic and expose a renderItem prop. The autocomplete component should not assume the result is only text.
How would you make this accessible?
Use the ARIA combobox pattern with role="combobox", aria-expanded, aria-controls, aria-activedescendant, a role="listbox" popup, and role="option" result items. Also support ArrowUp, ArrowDown, Enter, Escape, and Tab.
How would you improve perceived performance?
Use debounce, cache previous results, show cached results immediately, optionally reuse prefix results while fetching, delay loading indicators to avoid flicker, and keep result rendering lightweight.
Where should fuzzy search live?
Usually on the backend because ranking, typo tolerance, personalization, authorization, and analytics are backend concerns. The frontend should display the returned results and avoid duplicating ranking logic.
How would you support different design systems?
Expose behavior through a headless hook and provide render props or slots for input, item, popup, loading, empty, and error states. This allows each product to use its own styling and components.
How would you test this component?
Test the controller logic, request lifecycle, cache behavior, keyboard interactions, ARIA attributes, mouse selection, empty/loading/error states, and real browser behavior through end-to-end tests.
Final Recommendation
Build autocomplete as a headless, generic, accessible, cache-aware component with strong request lifecycle handling. Provide a default UI for simple use cases, but expose enough customization for product teams to render rich search experiences without forking the logic.