Autocomplete / Typeahead
Design a search suggestion box that stays responsive under fast typing, never renders a stale response, and is fully operable from the keyboard.
Advertisement
The Problem
Design the search suggestion box that sits at the top of a product. The user types, and within a couple of hundred milliseconds a list of suggestions appears beneath the input. Arrow keys move through them, Enter selects one, Escape dismisses the list. The matched portion of each suggestion is visually emphasised so the user can see why it matched.
It looks like a small widget. It is the single most common frontend system design prompt because it compresses three genuinely hard problems into one component: request scheduling under rapid input, concurrency correctness when responses arrive out of order, and a composite ARIA widget where the naive implementation is unusable with a screen reader.
Requirements
Functional
- Suggestions appear as the user types, without a submit action.
- Keyboard:
ArrowDown/ArrowUptraverse,Enterselects,Escapecloses,Tabmoves on. - The substring that matched is highlighted inside each suggestion.
- Distinct, non-ambiguous states for loading, results, no results, and failure.
- Selecting a suggestion fills the input and notifies the parent.
Non-functional
- No more than one network request per meaningful pause in typing.
- A response for a query the user has moved past must never be rendered.
- Repeating a query already typed in this session must not hit the network.
- Perceived latency under ~150ms for cached queries, under ~400ms for network queries.
- Fully operable by keyboard and announced correctly by screen readers.
Request Scheduling: Debounce vs Throttle
Every keystroke is a candidate trigger. A 20-character query typed at 150ms per character produces 20 candidate requests. Sending all of them wastes the user's bandwidth, burns server capacity on queries nobody will read, and makes the out-of-order problem in the next section far worse.
The two standard rate-limiting tools solve different problems, and picking the wrong one is a common interview stumble.
Throttling guarantees at most one call per interval, and it fires during the burst. With a 300ms throttle, a user typing continuously for 3 seconds produces about 10 requests, spread evenly.
Debouncing waits for the input to go quiet for N milliseconds and then fires once. The same 3 seconds of continuous typing produces exactly one request, after the user stops.
function debounce<Args extends unknown[]>(
fn: (...args: Args) => void,
waitMs: number,
) {
let timer: ReturnType<typeof setTimeout> | undefined;
const debounced = (...args: Args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), waitMs);
};
debounced.cancel = () => {
if (timer) clearTimeout(timer);
timer = undefined;
};
return debounced;
}
Each call clears the pending timer and schedules a new one, so the wrapped function only ever runs waitMs after the last call. The cancel method matters more than it looks: when the component unmounts, or when the user selects a suggestion, a timer that fires afterwards will set state on a dead component or reopen a list the user just dismissed.
Autocomplete wants debouncing, because intermediate queries have no independent value. The user typing s, sh, sha is not asking three questions; they are asking one question and you are watching them finish the sentence. Throttling is correct when every sample matters on its own - scroll position for a sticky header, pointer coordinates during a drag, a progress readout. Nothing in that list describes a search box.
Choosing the window is a real tradeoff:
| Window | Effect |
|---|---|
| 100-150ms | Feels instantaneous; fires on brief mid-word hesitation, so request volume stays high |
| 200-300ms | The common default. Roughly one request per typed word, still feels live |
| 400ms+ | Noticeably laggy on fast connections; only justified when each query is expensive |
Two refinements worth naming in an interview. First, do not debounce the first character if you also show recent or popular searches, since that panel is local and should appear instantly. Second, fire immediately on paste, because a paste is a completed intent with no pause to wait for - listen for the paste event and bypass the timer.
A separate decision is the minimum query length. Single-character queries match almost everything and produce useless suggestion lists at maximum server cost. Gating requests at two or three characters removes most of that waste for free.
The Race Condition
This is the part that separates a working autocomplete from one that only appears to work on a fast connection.
Debouncing reduces request count, but it does not serialise anything. Type sha, pause 300ms so a request goes out, then type rd and pause again. Two requests are now in flight. HTTP gives you no ordering guarantee across independent requests - the second one might resolve first because it hit a warm cache, or the first one might get stuck behind a slow shard.
If the response for sha resolves after the response for shard, and your handler naively assigns whatever arrives into state, the user sees suggestions for sha sitting under an input that reads shard. The bug is intermittent, worse on poor networks, and effectively invisible in local development.
Diagram100%sequenceDiagram participant U as User participant C as Component participant N as Network participant S as Search API U->>C: types "sha" C->>C: debounce 300ms C->>N: fetch /search?q=sha (seq 1) N->>S: request seq 1 U->>C: types "rd" -> "shard" C->>C: debounce 300ms C->>N: abort seq 1 C->>N: fetch /search?q=shard (seq 2) N->>S: request seq 2 S-->>N: results for "shard" N-->>C: resolve seq 2 C->>C: seq 2 === latest -> render S-->>N: results for "sha" (late) N-->>C: AbortError (or seq 1 !== latest) C->>C: discard, never touches statevisualized by
Note the two independent defences in that diagram. The abort stops the work; the sequence comparison guarantees correctness even if an abort is missed or lands too late.
Defence one: AbortController. Every fetch accepts a signal. Keep the controller for the in-flight request and abort it before issuing the next.
const controllerRef = useRef<AbortController | null>(null);
async function runSearch(query: string) {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
);
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
return (await response.json()) as Suggestion[];
} catch (error) {
// An abort is an expected outcome here, not a failure to report.
if (error instanceof DOMException && error.name === 'AbortError') return null;
throw error;
}
}
Aborting does more than protect state. It closes the connection, freeing one of the browser's limited per-origin slots, and lets a well-built server stop resolving a query nobody will read. On a mobile connection with six queued requests, that recovered capacity is the difference between responsive and stuck.
Defence two: the sequence guard. Aborts are not always available - you may be behind a request library without cancellation, or reading from a postMessage bridge or a shared worker. The guard works regardless: stamp each request and compare on arrival.
const latestRequestId = useRef(0);
async function search(query: string) {
const requestId = ++latestRequestId.current;
const results = await fetchSuggestions(query);
// A response is only allowed to win if nothing newer was issued.
if (requestId !== latestRequestId.current) return;
setSuggestions(results);
}
Comparing the query string instead of a counter works equally well and is easier to debug, with one subtlety: if the user backspaces from shard to sha while the sha request is still in flight, the strings match again and the older response is accepted. That is harmless here - it is genuinely the right data for the current input - but a monotonic counter is the more literal expression of "newest wins".
This is the request-lifecycle discipline described in Networking and Data Fetching, applied to the hardest case: an endpoint being called faster than it can answer.
Caching Prior Queries
Typeahead traffic is unusually repetitive. Users overshoot and backspace, retype a query they already ran, and re-open the search box to run the same query again. Every one of those should be free.
const MAX_CACHE_ENTRIES = 50;
class SuggestionCache {
private entries = new Map<string, Suggestion[]>();
private normalise(query: string) {
return query.trim().toLowerCase();
}
get(query: string) {
const key = this.normalise(query);
const hit = this.entries.get(key);
if (!hit) return undefined;
// Re-insert so this key becomes the most recently used.
this.entries.delete(key);
this.entries.set(key, hit);
return hit;
}
set(query: string, results: Suggestion[]) {
const key = this.normalise(query);
this.entries.delete(key);
this.entries.set(key, results);
if (this.entries.size > MAX_CACHE_ENTRIES) {
// Map preserves insertion order, so the first key is the LRU one.
this.entries.delete(this.entries.keys().next().value as string);
}
}
}
A Map gives you LRU behaviour almost for free because it preserves insertion order: delete-then-set moves a key to the back, and the first key is always the least recently used.
Three rules that matter more than the data structure:
- Check the cache before the debounce, not after. A cache hit should render synchronously on the current keystroke. Waiting 300ms to serve data you already have is the whole point thrown away.
- Never cache failures. A timeout is not the answer "no results". Cache only successful, populated responses, or you will pin a broken query to an empty list for the rest of the session.
- Bound staleness deliberately. For product search where inventory shifts, a 60-second TTL per entry is reasonable. For a static reference list, session lifetime is fine.
When the server does prefix search, one extra optimisation becomes safe. If the new query extends a cached one (shard extends shar) and the cached result set was not truncated by a server-side limit, every valid result for shard is already in the shar set, so you can filter locally with zero requests. This is invalid for fuzzy search, where adding a character can add matches through typo tolerance - a caveat worth stating out loud, because it shows you understand what the server is doing rather than treating it as a black box.
The State Machine
Autocomplete has more states than teams expect, and the bugs users report - a spinner that never clears, "no results" flashing before results appear, an empty dropdown after a network failure - are all missing transitions rather than broken rendering.
Diagram100%stateDiagram-v2 [*] --> Idle Idle --> Typing: input changes Typing --> Idle: input cleared Typing --> Typing: more input (timer resets) Typing --> Loading: debounce elapsed Typing --> Results: cache hit Loading --> Results: response has items Loading --> Empty: response has zero items Loading --> Error: request failed Loading --> Loading: newer query (abort + refetch) Results --> Typing: input changes Results --> Selected: Enter or click Empty --> Typing: input changes Error --> Typing: input changes Error --> Loading: retry Selected --> Idle: popup closesvisualized by
Two transitions carry most of the perceived quality.
Typing -> Results on a cache hit skips loading entirely. Without it, known queries still flash a spinner and the widget feels slower than it is.
Loading -> Loading on a newer query keeps the previous results on screen while the new request runs, rather than blanking the list. Replacing results with a spinner on every keystroke makes the dropdown flicker; keeping stale results visible under a subtle loading indicator reads as fast and stable. This is the stale-while-revalidate posture from State Management Architecture applied at the scale of a single input.
Practical rules for the visible states:
- Loading: only show a spinner if the request outlives ~200ms. Anything faster is a flash of noise. On a first-ever query show skeleton rows; on a refinement keep old results dimmed.
- Empty: reachable only from a successful response with zero items, and it should say what was searched: "No results for shardd". Never render empty as the fallback for an error.
- Error: distinct copy and a retry affordance. Silently showing an empty list on failure teaches the user that their query has no matches, which is a lie.
Substring Highlighting
Highlighting looks trivial and is the most common place an autocomplete grows an XSS hole. The naive implementation string-replaces the query with <mark> tags and assigns the result to innerHTML (or dangerouslySetInnerHTML). Both the suggestion text and the query come from outside your control, so that is a direct injection path - exactly the pattern warned about in Security Architecture.
Split into segments and let the framework escape them:
type Segment = { text: string; matched: boolean };
export function splitOnMatch(label: string, query: string): Segment[] {
const needle = query.trim().toLowerCase();
if (!needle) return [{ text: label, matched: false }];
const segments: Segment[] = [];
const haystack = label.toLowerCase();
let cursor = 0;
while (cursor < label.length) {
const hit = haystack.indexOf(needle, cursor);
if (hit === -1) {
segments.push({ text: label.slice(cursor), matched: false });
break;
}
if (hit > cursor) {
segments.push({ text: label.slice(cursor, hit), matched: false });
}
// Slice from the original label so the suggestion keeps its own casing.
segments.push({ text: label.slice(hit, hit + needle.length), matched: true });
cursor = hit + needle.length;
}
return segments;
}
<span>
{splitOnMatch(suggestion.label, query).map((segment, index) =>
segment.matched ? (
<mark key={index} className='bg-transparent font-semibold'>
{segment.text}
</mark>
) : (
<span key={index}>{segment.text}</span>
),
)}
</span>
Note the details. Matching is case-insensitive but the rendered text is sliced from the original label, so iphone typed by the user still displays as iPhone. <mark> is the semantically correct element for relevance highlighting, and styling it with weight rather than a background colour avoids relying on colour alone to convey meaning.
Two cases worth handling: for fuzzy search the server should return match ranges, since the client cannot reconstruct which characters matched - trust those offsets rather than guessing. And when text is normalised for matching (stripping accents so cafe matches café), index arithmetic on the original string breaks unless you keep an index map. Two conventions here interact badly with naive slicing - locale-aware collation and normalisation forms - both covered in Internationalization Architecture.
Keyboard Navigation and ARIA
An autocomplete is a combobox: a text input paired with a popup listbox. Getting the markup right is what makes it usable at all for keyboard and screen reader users, and it is the part candidates most often skip.
The central constraint: focus must never leave the input. The user is still typing. So the highlighted option is tracked with aria-activedescendant, which points at the id of the visually active option while focus stays put.
<input
role='combobox'
aria-expanded={isOpen}
aria-controls='suggestion-listbox'
aria-autocomplete='list'
aria-activedescendant={
activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={onKeyDown}
/>
{isOpen && (
<ul role='listbox' id='suggestion-listbox'>
{suggestions.map((suggestion, index) => (
<li
key={suggestion.id}
id={`suggestion-${index}`}
role='option'
aria-selected={index === activeIndex}
// Pointer down, not click: click fires after blur and closes the list first.
onMouseDown={(event) => {
event.preventDefault();
onSelect(suggestion);
}}>
{/* highlighted label */}
</li>
))}
</ul>
)}
<div role='status' aria-live='polite' className='sr-only'>
{isOpen ? `${suggestions.length} suggestions available` : ''}
</div>
The key handler owes the user a specific contract:
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (!isOpen) {
// ArrowDown reopens a list that was dismissed with Escape.
if (event.key === 'ArrowDown' && suggestions.length > 0) setIsOpen(true);
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault(); // stop the caret jumping to end of input
setActiveIndex((current) => (current + 1) % suggestions.length);
break;
case 'ArrowUp':
event.preventDefault();
setActiveIndex((current) =>
current <= 0 ? suggestions.length - 1 : current - 1,
);
break;
case 'Home':
event.preventDefault();
setActiveIndex(0);
break;
case 'End':
event.preventDefault();
setActiveIndex(suggestions.length - 1);
break;
case 'Enter':
if (activeIndex >= 0) {
event.preventDefault(); // do not submit the form
onSelect(suggestions[activeIndex]);
}
break;
case 'Escape':
// First Escape closes the popup; a second one is left to the browser
// (and to any dialog the combobox sits inside).
setIsOpen(false);
setActiveIndex(-1);
break;
case 'Tab':
setIsOpen(false); // let focus move on naturally
break;
}
}
Four details that are routinely missed:
preventDefault()on the arrow keys. Without it the browser also moves the text caret to the start or end of the input, so the caret jumps around while the user navigates the list.onMouseDowninstead ofonClickfor selection.blurfires beforeclick, so a handler that closes the popup on blur destroys the option before the click lands - the classic "clicking a suggestion does nothing" bug.aria-selectedon options, not the input. It marks which option is active within the listbox.- A polite live region announcing the result count, so a screen reader user learns that suggestions appeared without having to arrow into them to find out.
Whether wrapping is right at the list edges is a genuine judgement call: wrapping is faster for short lists, clamping is less disorienting for long ones. Either is defensible; silently doing nothing is not. The full reasoning behind these primitives - focus management, live regions, why aria-activedescendant exists at all - is in Accessibility Architecture.
What the Server Should Do
Even though this is a frontend design, the server contract shapes the client, and an interviewer will expect you to know why.
Prefix search is typically a trie or an indexed range scan. It is fast, cheap, and monotonic - adding a character can only narrow the result set. That monotonicity is what makes local filtering of a cached parent query safe.
Fuzzy or full-text search uses edit distance, n-grams, or a vector index. It tolerates typos and matches mid-word, which users prefer, but results can appear, vanish and reorder as characters are added. Local narrowing is unsafe, every query must go to the server, and per-query cost is higher - which pushes the debounce window up and makes client caching more valuable, not less.
Two contract details worth insisting on:
- A hard result limit (typically 5-10). The dropdown cannot show more, and a large payload only costs latency.
- Match ranges in the response for fuzzy search, so highlighting reflects what actually matched rather than a client-side guess.
Common Interview Follow-Up Questions
"The suggestions endpoint takes 800ms at p95. What can you do on the frontend?"
Nothing makes the request faster, so the work is hiding the latency and reducing how often you pay it. Keep previous results visible while revalidating so the list never blanks. Prefetch on focus - fire the popular/recent panel when the input is focused, before any typing. Increase the debounce window slightly, since at 800ms per query the cost of a wasted request is high. Cache aggressively so backspacing and retyping are free. Fire on paste immediately. And instrument it: p95 as measured from the browser, cache hit rate, and abort rate are the three numbers that tell you whether the fix worked - see Observability.
"How would you support arrow-key navigation through a list of 10,000 suggestions?"
You would not render 10,000 options. Autocomplete should cap suggestions server-side at what a dropdown can usefully show. If a product genuinely needs a long scrollable picker - a country list, a full inventory - the popup becomes a windowed list, and aria-activedescendant then needs the active option to actually exist in the DOM, which means scroll-into-view logic tied to the virtualiser and aria-setsize / aria-posinset so assistive tech knows the true length. That machinery is covered in Virtualized List.
"Two components on the page both search the same endpoint. How do you avoid duplicate requests?" Lift the request out of the component into a shared query layer keyed by the normalised query, with in-flight deduplication: the second caller for a key that already has a pending promise subscribes to it instead of starting a new fetch. That is precisely what a client cache with request deduplication gives you, and why autocomplete state belongs in the server-state layer rather than in component state - the categorisation argument from State Management Architecture.
"How do you test this?"
Three layers. Unit-test the pure pieces: splitOnMatch against overlapping, repeated and empty matches; the debounce with fake timers. Integration-test the widget with a mocked network that resolves out of order on purpose - the stale-response test is the one that catches real regressions, and it must assert that the sha response never lands. Then keyboard-drive the whole thing in a real browser: arrow, Enter, Escape, and a check that aria-activedescendant tracks the highlight. The pyramid reasoning is in Testing Strategy.
"The user types, then goes offline. What should happen?"
The request fails, so you land in Error - not Empty. The distinction matters because the copy differs: "You appear to be offline" with a retry, not "No results". If the cache holds entries for that query, serve them and mark the list as possibly stale rather than showing an error at all. The queue-and-replay machinery in Offline and PWA Architecture is overkill for a read like this - a search is safe to simply drop and retry.
Tradeoffs Table
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| Debounce input (200-300ms) | One request per typed word, minimal waste, simple | Adds the window to perceived latency; a badly chosen value feels laggy | Default for every text-driven suggestion box |
| Throttle input | Steady request rate, results update mid-burst | Most requests describe queries the user has already moved past | Only when intermediate values matter independently, which autocomplete's do not |
| AbortController cancellation | Frees connections, stops useless server work, prevents stale renders | Needs cancellation support through the whole request path; aborts must be distinguished from real errors | Any client-side search where queries are issued faster than they resolve |
| Sequence-id guard | Works with any transport, trivial to implement, no dependencies | Wasted work still completes; connection stays occupied | As a correctness backstop alongside cancellation, or when cancellation is unavailable |
| Client LRU cache of queries | Instant results on backspace and retype, big drop in request volume | Can serve stale suggestions; needs bounding and TTL discipline | Any typeahead, with a TTL matched to how fast the underlying data changes |
| Local filtering of a cached parent query | Zero requests while the query narrows | Only correct for prefix search on an untruncated result set | Prefix-search backends with generous result limits |
Where This Applies
This design is where the request-lifecycle discipline from Networking and Data Fetching stops being theoretical: cancellation, deduplication and cache keying are the difference between a typeahead that feels instant and one that renders yesterday's answer. The decision to treat suggestions as cached server state rather than component state comes straight out of State Management Architecture, and the combobox markup is the hardest of the composite widgets described in Accessibility Architecture.
The same primitives reappear across this track. Request cancellation on rapidly changing input drives the real-time feed and the widget refreshes in a dashboard. The listbox-and-keyboard contract is the backbone of the date picker. And if a picker ever has to hold thousands of options, the windowing techniques in Virtualized List are what keep it interactive.
Advertisement
Why is debouncing the right choice for autocomplete rather than throttling?
Because the value of an intermediate query is close to zero. Throttling guarantees a request at a fixed rate while the user keeps typing, so typing "keyboard" at 150ms per character with a 300ms throttle fires roughly four requests, three of which describe prefixes the user has already moved past. Debouncing waits for a pause and fires once, on the only query the user actually cared about. Throttling is the better tool when every intermediate value has independent value and must be observed - scroll position, cursor tracking, a live progress readout - which is exactly not the case for a search box where the last value supersedes all previous ones.
A user types "sha" then "shar" then "shard". The response for "sha" arrives last and overwrites the results for "shard". How do you prevent that?
This is an out-of-order response race, and the fix is to make responses that no longer correspond to the current query unusable. The strongest option is AbortController - keep a reference to the in-flight request's controller, call abort() before starting a new one, and the stale fetch rejects with an AbortError that you swallow, so it can never reach your state setter. A cheaper option that requires no cancellation support is a sequence guard - store a monotonically increasing request id or the query string itself in a ref, and when a response resolves, compare the id it was issued under against the current one and discard the response if they differ. Cancellation is better because it also frees the connection and stops the server doing work nobody will read, but the guard is what protects correctness, so in practice you want both.
Should autocomplete results be cached on the client, and what is the risk?
Yes, because typeahead traffic is extremely repetitive - backspacing from "shard" to "shar" should never re-hit the network, and a plain Map keyed by the normalised query gives you an instant render for every prefix the user has already seen in this session. The risk is unbounded growth and staleness. Growth is bounded with an LRU cache capped at a few dozen entries, which is far more than any single search session needs. Staleness matters only if suggestions change within a session, so a short time-to-live of a minute or two, or clearing the cache after a mutation that could change the result set, is usually enough. Never cache error responses or empty results from a failed request, or you will serve a permanent "no results" for a query that was only briefly broken.
What ARIA pattern does an autocomplete use, and why is aria-activedescendant needed instead of moving focus?
It is the combobox pattern - a text input with role combobox, aria-expanded reflecting whether the popup is open, and aria-controls pointing at a listbox of option elements. The reason you cannot move DOM focus onto the highlighted option is that the user is still typing, so focus must remain on the input for keystrokes to reach it. aria-activedescendant solves exactly this split - it names the id of the visually highlighted option while focus stays on the input, and screen readers announce that option as the active one. Moving real focus into the list would break typing, break the arrow-key contract, and make the widget unusable with a screen reader.
How does the choice between prefix search and fuzzy search on the server change the frontend design?
Prefix search is cheap and predictable - a trie or an indexed range scan returns terms that literally start with the typed characters, so results are stable, latency is low and results only ever narrow as the user types. That lets the frontend filter a previously fetched result set locally when the new query extends the old one, avoiding a request entirely. Fuzzy or full-text search tolerates typos and matches mid-word, which is friendlier but means results can appear, disappear and reorder unpredictably as characters are added, so local narrowing is unsafe and every query must go to the server. Fuzzy search is also slower and more expensive per query, which pushes the debounce window up and makes client caching more valuable, not less.