#Design an autocomplete component
Added"Design an autocomplete" is the most common frontend system design prompt because it looks like one text box and hides four real problems. As the user types, you must not send a request for every keystroke (load), answers that come back out of order must not overwrite newer ones (races), the same query must not be fetched twice (caching), and the whole thing must work from the keyboard and with a screen reader (accessibility). A candidate who builds the box and the dropdown has done the easy 20%; the round is about those four.
A mental model that covers the first three: you're asking a slow, unreliable librarian for books while you're still deciding what you want. You don't shout every letter as you think of it; you wait until you pause (debounce). If two answers come back, you only care about the one to your latest question, however late or early it arrives (latest request wins). And you write every answer down, so asking the same thing again costs nothing (a per-query cache).
Run the round in the RADIO order. Here's where this design lands in each step, so you can see the whole answer before the detail:
| Step | What you decide for autocomplete |
|---|---|
| Requirements | Server-backed suggestions, keyboard and screen-reader support, works on phones, reusable for other data sources |
| Architecture | A headless controller (debounce, race guard, cache) under thin ComboboxInput and SuggestionList components |
| Data model | query, status, items, activeIndex, open, plus a Map cache keyed by normalised query |
| Interface | Props fetchSuggestions(q, { signal }), onSelect(item), renderItem; server GET /suggest?q=&limit= |
| Optimisations | Debounce vs throttle, AbortController, prefix reuse, IME composition, safe highlighting, CDN caching |
The heart of the design is a few lines of control logic. This model runs it against a fake clock and a fake server whose answers take different times, so the out-of-order case actually happens:
// A fake clock: timers only fire when advance() moves time forward.
let now = 0;
const timers = [];
const later = (fn, ms) => { const t = { at: now + ms, fn }; timers.push(t); return t; };
const cancel = (t) => timers.includes(t) && timers.splice(timers.indexOf(t), 1);
async function advance(to) {
for (let t; (t = timers.sort((a, b) => a.at - b.at)[0]) && t.at <= to; ) {
timers.shift(); now = t.at; t.fn();
await new Promise((r) => setImmediate(r)); // let promise callbacks run
}
now = to;
}
// Fake server: each query has its own latency, so answers can arrive out of order.
const latency = { rea: 600, react: 100 };
let calls = 0;
const fetchSuggestions = (q) => {
calls++; console.log(`${now}ms request "${q}"`);
return new Promise((ok) => later(() => ok([`${q} one`, `${q} two`]), latency[q] ?? 150));
};
// The component's logic: debounce, a per-query cache, and latest-request-wins.
function createAutocomplete({ wait = 250, render }) {
const cache = new Map();
let timer, latest = 0;
async function run(q) {
const id = ++latest; // this request's ticket
if (cache.has(q)) return render(q, cache.get(q), "cache");
const list = await fetchSuggestions(q);
cache.set(q, list); // cache every answer, even a late one
if (id !== latest) return console.log(`${now}ms drop stale "${q}"`);
render(q, list, "network");
}
return { input(q) { cancel(timer); timer = later(() => run(q), wait); } };
}
const ac = createAutocomplete({
render: (q, list, from) => console.log(`${now}ms show "${q}" from ${from}: ${list.join(", ")}`),
});
// The user types "rea", pauses, types "ct", then deletes back to "rea".
const keys = [[0, "r"], [120, "re"], [240, "rea"], [600, "reac"], [700, "react"], [1500, "reac"], [1600, "rea"]];
for (const [at, q] of keys) { await advance(at); ac.input(q); }
await advance(3000);
console.log(`${keys.length} keystrokes, ${calls} requests`);490ms request "rea"
950ms request "react"
1050ms show "react" from network: react one, react two
1090ms drop stale "rea"
1850ms show "rea" from cache: rea one, rea two
7 keystrokes, 2 requests- The fake clock keeps timers in a list and only runs them when
advance(to)moves time forward, so the run is exact and repeatable. After each timer it waits onesetImmediate, which lets theawaitinsiderun()continue, the same way a real event loop would between a network response and your code. - Keystrokes at
0,120and240ms each callinput(), which cancels the previous timer and starts a new 250 ms one. Only the last survives, so the first request goes out at240 + 250 = 490ms for"rea". Three keystrokes, one request. "rea"is slow (600 ms, back at1090). Meanwhile the user types"reac"and"react"; the debounce fires at700 + 250 = 950and asks for"react", which is fast and returns at1050. Its ticketidis2, equal tolatest, so it's shown.- At
1090the slow"rea"answer finally arrives. Its ticket is1butlatestis2, so it's dropped instead of overwriting the newer list. Without that one comparison the user would be looking at "react" in the box and suggestions for "rea" underneath. The answer is still written to the cache: it's correct data, just not for now. - The user deletes back to
"rea". The debounce fires at1850, finds"rea"in the cache and renders it with no request. Total: 7 keystrokes, 2 requests. - If you changed the order of
++latestand the cache check (bumping the ticket only for network requests), a cache hit would not invalidate a request still in flight, and that older request could land on top of the cached list. Every new query must take a ticket, whatever path it takes.
Requirements
Ask before you draw. The answers change the design more than any library choice.
- Where do suggestions come from? A fixed list of 250 countries ships with the page and is filtered in memory, no network at all. Millions of products or users need a server with a prefix index (the backend side is Design search autocomplete). Say which one you're designing; usually it's the server-backed one.
- What is a suggestion? Plain text, or rich rows (avatar, category, price)? Grouped by type ("People", "Products")? Rich rows change the item model and the rendering cost.
- What happens on select? Fill the box and search, navigate to a page, or add a chip (a multi-select)? This decides the
onSelectcontract. - Empty state: recent searches or trending items when the box is focused but empty?
- Minimum characters before searching (often 1 for names, 2–3 for full-text).
- Reuse: one search box, or a component the whole company will use for any data source? Reuse pushes you to a headless core with the data source passed in.
Non-functional requirements, written on the board:
- Feels instant. A cached answer appears with no network; a network answer within a few hundred milliseconds of the pause. Typing itself must never lag, which means rendering the list can't block the input (INP of 200 ms or less at the 75th percentile is Google's "good" line, web.dev, checked October 2026).
- Kind to the server. One request per pause, not per keystroke.
- Correct under races. The list always matches the text in the box.
- Accessible. Follows the WAI-ARIA Authoring Practices combobox pattern: arrow keys move through options, Enter selects, Escape closes, screen readers hear the active option.
- Works on phones and with input methods (Japanese, Chinese and Korean typists compose characters before committing them).
Estimates
Frontend estimates are about what the client causes: requests per user action, bytes per response and work per keystroke. The typing speed comes from the largest public typing study, 136 million keystrokes from 168,000 volunteers (Dhakal et al., CHI 2018), which measured an average of 52 words per minute. The DAU, searches per user and pause count are assumptions to state out loud:
// Assumptions (stated in the text): typing speed from the 136M-keystroke study, the rest are design inputs.
const wpm = 52; // average typist (Dhakal et al., CHI 2018)
const charsPerWord = 5; // the standard WPM convention
const queryLength = 12; // characters in a typical search
const searchesPerUserPerDay = 6;
const dau = 20_000_000;
const peakFactor = 3; // busiest hour vs the daily average
const debounceMs = 250;
const pausesPerQuery = 2; // a user stops typing about twice per query
const msPerKey = 60_000 / (wpm * charsPerWord);
const perQueryNoDebounce = queryLength; // one request per keystroke
const perQueryDebounced = pausesPerQuery; // one request per pause longer than debounceMs
const qps = (perQuery) => Math.round((dau * searchesPerUserPerDay * perQuery) / 86_400 * peakFactor);
console.log(`average gap between keys: ${msPerKey.toFixed(0)} ms (debounce ${debounceMs} ms waits for a pause)`);
console.log(`peak QPS, request per keystroke: ${qps(perQueryNoDebounce).toLocaleString("en")}`);
console.log(`peak QPS, debounced: ${qps(perQueryDebounced).toLocaleString("en")}`);
console.log(`saved by debouncing: ${(100 * (1 - perQueryDebounced / perQueryNoDebounce)).toFixed(0)}%`);
const payload = 8 * 60; // 8 suggestions × ~60 bytes of JSON each
console.log(`suggestion payload: ~${payload} bytes, ~${(payload * qps(perQueryDebounced) * 8 / 1e9).toFixed(2)} Gbit/s at peak`);average gap between keys: 231 ms (debounce 250 ms waits for a pause)
peak QPS, request per keystroke: 50,000
peak QPS, debounced: 8,333
saved by debouncing: 83%
suggestion payload: ~480 bytes, ~0.03 Gbit/s at peak- 52 words per minute at the standard 5 characters per word is 260 characters a minute, so an average typist presses a key every
60 000 / 260 = 231ms. (The study also measured the gap directly: 238 ms on average, the same conclusion.) A 250 ms debounce is just above that gap: it waits through ordinary typing and fires on a real pause. That's why 250–300 ms is the usual setting; treat it as a rule of thumb you tune with real data. - Without debouncing, a 12-character query is 12 requests. At 20 million users doing 6 searches a day, with a 3× peak, that's
20M × 6 × 12 / 86 400 × 3 = 50 000requests per second. - With debouncing, each query fires roughly once per pause. With two pauses per query the same traffic is 8,333 requests per second, 83% less. That's a backend capacity number you got from a frontend decision, which is exactly the kind of link interviewers like.
- Responses are tiny (8 suggestions, about 480 bytes), so bandwidth is not the constraint (0.03 Gbit/s at peak). Request count and server CPU are, which is why the debounce and the cache matter more than compression here.
Architecture
Split the component into a headless controller that owns the behaviour and thin presentational pieces that own the markup. The controller is where debounce, the race guard and the cache live, and it can be tested in Node like the model above. The same controller can then sit under a search box, a user picker or a tag input. This is the container/presentational split from Component architecture applied to one widget.
Walk the picture left to right when you present it: the input emits keystrokes, the controller decides whether and when to ask, the API client makes a cancellable request, and the CDN answers popular prefixes without reaching the service. Items flow back into the controller (cache first, then state) and the list renders them.
Data model
The state is small, and every field has a reason to exist:
const state = {
query: "rea", // what's in the box (the controlled input value)
status: "success", // "idle" | "loading" | "success" | "error"
items: [ // what the list shows, always for `query`
{ id: "t:react", label: "react", type: "topic", match: [0, 3] },
],
activeIndex: -1, // highlighted option, -1 = none (drives aria-activedescendant)
open: true, // is the listbox shown (drives aria-expanded)
};
// Outside the rendered state, because changing it shouldn't re-render anything:
const cache = new Map(); // normalised query → { items, fetchedAt }, LRU-capped at ~100 entries
let latest = 0; // the race-guard ticketqueryanditemsare kept separate on purpose: the list may still be showing the previous query's items while a new request is in flight (showing old results dimmed is calmer than flashing an empty list on every keystroke).statusis one field with four values rather thanisLoadingandisErrorbooleans, so impossible combinations (loading and error) can't be represented.- Each item has a stable
id, used for the option's DOMidand as the React key, and amatchrange so the highlight is computed by the server or the controller, not by re-parsing HTML. cacheandlatestlive outside rendered state (a ref in React, a plain field in an Angular service). They change on every request, and re-rendering for them would cost work and show nothing.- The cache key is the normalised query: trimmed, lower-cased, with the locale and any filters included (
en|rea), otherwise two users' or two languages' answers would collide.
Interface
The component's API, for the teams that will use it:
type Props<T> = {
fetchSuggestions: (query: string, opts: { signal: AbortSignal }) => Promise<T[]>;
getKey: (item: T) => string;
getLabel: (item: T) => string; // the text put in the box on select
renderItem?: (item: T, state: { active: boolean; query: string }) => ReactNode;
onSelect: (item: T) => void;
minChars?: number; // default 1
debounceMs?: number; // default 250
maxItems?: number; // default 8
label: string; // the visible <label>; required, not optional
};fetchSuggestionsis passed in, which is what makes the component reusable: the user picker passes one that calls/users/search, the product search one that calls/suggest. It receives anAbortSignalso the component can cancel it.getKeyandgetLabelkeep the component generic over the item typeTinstead of assuming{ id, name }.renderItemis the escape hatch for rich rows; the component still owns the option element, itsid,roleandaria-selected, so a custom renderer can't break accessibility.labelis required. An autocomplete without a programmatic label is announced as "edit text" with no name, and making the prop optional guarantees someone will forget it.- This sketch was not compiled (it's an excerpt in React style); the same contract works as Angular
input()andoutput()members.
The server API it needs:
GET /api/suggest?q=rea&limit=8&locale=en
200 OK
Cache-Control: public, max-age=60
{ "query": "rea", "items": [ { "id": "t:react", "label": "react", "type": "topic", "match": [0, 3] } ] }Two details are worth saying. The response echoes the query, which gives the client a second way to drop stale answers (if response.query isn't the current query, ignore it). And it's a cacheable GET with a short public max-age, so a CDN can answer the most popular prefixes without reaching the service at all. Personalised suggestions (recent searches) can't be shared like that; fetch them separately with private caching, or merge them on the client.
The ticket in the main model ignores a stale answer after it arrives. The other approach is to cancel the old request as soon as a new one starts, with an AbortController. This model uses a fake fetch that honours the signal exactly as fetch() does:
// The other fix for out-of-order answers: cancel the previous request when a new one starts.
// fakeFetch honours an AbortSignal the way fetch() does: it rejects with an AbortError.
let served = 0;
function fakeFetch(q, { signal }, ms) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => { served++; resolve(`results for "${q}"`); }, ms);
signal.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true });
});
}
let controller = null;
async function search(q, ms) {
controller?.abort(new DOMException(`superseded by "${q}"`, "AbortError"));
controller = new AbortController();
try {
console.log("show", await fakeFetch(q, { signal: controller.signal }, ms));
} catch (e) {
if (e.name === "AbortError") console.log(`cancel "${q}" (${e.message})`); // expected: not an error to show
else throw e;
}
}
const done = [search("rea", 60), search("reac", 40), search("react", 10)];
await Promise.all(done);
console.log(`responses the client waited for: ${served} of 3`);cancel "rea" (superseded by "reac")
cancel "reac" (superseded by "react")
show results for "react"
responses the client waited for: 1 of 3search("rea")creates a controller and starts a 60 ms request. Before it finishes,search("reac")callscontroller.abort(...)on the old one, which fires itsabortlistener synchronously: the timer is cleared and the promise rejects with theAbortErrorpassed as the reason.- The
catchseese.name === "AbortError"and treats it as normal control flow. Showing "Something went wrong" for a request the component cancelled on purpose is a classic bug; every other error is re-thrown. "reac"is cancelled the same way by"react", which completes and is shown. Only 1 of the 3 responses was ever waited for.- The difference from the ticket approach: aborting frees the browser's connection and stops downloading the body, but the server has usually already done the work. Abort saves the client, not the backend; the debounce is what saves the backend.
- In production use both: abort the previous request, and keep the ticket check, because an answer can already be resolved and queued as a microtask when the abort happens. Belt and braces costs two lines.
Debounce waits for a pause, which is right for most search boxes. Its weakness is that a fast, continuous typist sees nothing until they stop. Throttle (at most one request per interval, say every 300 ms, while typing continues) shows results that update during typing, at the cost of more requests. Some products use both: a leading-edge request on the first character (so the list appears at once), then debounce. Say which you pick and why: for a global search box with millions of users, debounce, because the backend cost dominates; for an in-app picker over a small dataset, throttle or no delay at all, because there's no backend to protect.
Two cheap tricks make it feel faster without more requests:
- Serve the cache immediately, without waiting for the debounce. In the model, the cached
"rea"still waited 250 ms; skipping the timer on a cache hit makes backspacing feel instant. - Filter what you already have. If the cached list for
"rea"was complete (fewer items thanlimit), the answer for"reac"is a subset of it: filter locally and skip the request.
The WAI-ARIA Authoring Practices "list autocomplete" combobox example (W3C APG, checked October 2026) defines the contract: DOM focus stays in the text box the whole time, the highlighted option is pointed to with aria-activedescendant, aria-expanded says whether the list is shown, and aria-controls links the box to the list. Down Arrow moves to the next option and wraps, Enter accepts the option, Escape closes the list. Writing the keyboard logic as a pure reducer makes it testable without a browser:
// The combobox's keyboard behaviour as a pure reducer: state in, key in, new state out.
// active is the highlighted option's index, or -1 when focus is only in the text box.
function onKey(state, key) {
const n = state.options.length;
switch (key) {
case "ArrowDown":
if (!state.open) return { ...state, open: n > 0, active: n > 0 ? 0 : -1 };
return { ...state, active: (state.active + 1) % n }; // wraps last → first
case "ArrowUp":
if (!state.open) return { ...state, open: n > 0, active: n - 1 };
return { ...state, active: (state.active - 1 + n) % n }; // wraps first → last
case "Enter":
if (state.active < 0) return { ...state, submitted: state.value };
return { ...state, value: state.options[state.active], open: false, active: -1 };
case "Escape":
return state.open ? { ...state, open: false, active: -1 } : { ...state, value: "" };
default:
return state;
}
}
// What the DOM attributes become for a state (what a screen reader is told).
const aria = (s) => ({
"aria-expanded": String(s.open),
"aria-activedescendant": s.active >= 0 ? `opt-${s.active}` : "",
});
let s = { value: "rea", options: ["react", "react native", "readme"], open: true, active: -1 };
for (const key of ["ArrowDown", "ArrowDown", "ArrowUp", "ArrowUp", "Enter", "Escape"]) {
s = onKey(s, key);
console.log(key.padEnd(9), `value="${s.value}"`.padEnd(16), JSON.stringify(aria(s)));
}ArrowDown value="rea" {"aria-expanded":"true","aria-activedescendant":"opt-0"}
ArrowDown value="rea" {"aria-expanded":"true","aria-activedescendant":"opt-1"}
ArrowUp value="rea" {"aria-expanded":"true","aria-activedescendant":"opt-0"}
ArrowUp value="rea" {"aria-expanded":"true","aria-activedescendant":"opt-2"}
Enter value="readme" {"aria-expanded":"false","aria-activedescendant":""}
Escape value="" {"aria-expanded":"false","aria-activedescendant":""}- The state starts open with three options and nothing active (
active: -1), the moment after suggestions arrive. Focus is in the input, andaria-activedescendantis empty. - Down Arrow moves
active-1 → 0 → 1;ariaturns that intoaria-activedescendant="opt-0", then"opt-1". A screen reader announces the option that the attribute points to, although DOM focus never left the input; that's what lets the user keep typing. - Up Arrow goes
1 → 0, then from0wraps to the last option ((0 - 1 + 3) % 3 = 2). Wrapping is what the APG example does; some designs stop at the ends instead, and either is fine if it's consistent. - Enter with an active option copies its label into the box (
"readme"), closes the list and clears the active option:aria-expandedbecomes"false". Enter with nothing active would submit what was typed instead. - Escape on a closed list clears the box, the optional second Escape behaviour in the APG pattern; on an open list it only closes it. Each key has exactly one meaning per state, which is why a reducer (state, key) → state fits so well.
The markup that state produces, as a static live example (the frame runs no scripts, so this is the "Down Arrow pressed twice" moment, rendered):
.ac { width: 260px; font: 14px system-ui, sans-serif; }
.ac label { display: block; margin-bottom: 4px; font-weight: 600; }
.ac input { box-sizing: border-box; width: 100%; height: 34px; padding: 0 10px; border: 1px solid #94a3b8; border-radius: 8px; }
.ac ul { list-style: none; margin: 4px 0 0; padding: 4px; border: 1px solid #cbd5e1; border-radius: 8px; }
.ac li { height: 30px; line-height: 30px; padding: 0 8px; border-radius: 6px; }
.ac li[aria-selected="true"] { background: #dbeafe; color: #1e3a8a; }
.ac mark { background: none; font-weight: 700; color: inherit; }<div class="ac">
<label for="q">Search</label>
<input id="q" type="text" value="rea" role="combobox" aria-autocomplete="list"
aria-expanded="true" aria-controls="q-list" aria-activedescendant="opt-1">
<ul id="q-list" role="listbox" aria-label="Suggestions">
<li id="opt-0" role="option" aria-selected="false"><mark>rea</mark>ct</li>
<li id="opt-1" role="option" aria-selected="true"><mark>rea</mark>ct native</li>
<li id="opt-2" role="option" aria-selected="false"><mark>rea</mark>dme</li>
</ul>
</div>- Chrome's accessibility tree (read by the test) reports the input as role
comboboxwith the nameSearch, taken from the<label for="q">. That name is what a screen reader says on focus. - The
<ul>is alistboxand each<li>anoption. The option's accessible name is its text,"react native"; the<mark>around the matched part doesn't split it, so highlighting costs nothing in accessibility. aria-activedescendant="opt-1"resolves to the option witharia-selected="true", and the CSS keys the highlight off that same attribute (rgb(219, 234, 254)on it, transparent on the others). Styling from the ARIA state means the visual highlight and what the screen reader hears can never disagree.- In the real component, the controller writes these attributes on every state change, and also scrolls the active option into view, which the APG example does too. A polite live region announcing "3 suggestions" when results arrive tells screen-reader users the list exists at all.
Trade-offs and what to say
| Decision | Choose | Because | The other option wins when |
|---|---|---|---|
| When to request | Debounce 250 ms | One request per pause; 83% fewer requests in the estimate | Throttle or no delay: small or local data, live-updating results matter more |
| Stale answers | Abort + ticket check | Abort saves the client; the ticket closes the last race | Ticket only: the data source isn't abortable (a WebSocket, a worker) |
| Caching | In-memory Map, LRU ~100, short TTL | Backspacing and retyping are free | Persist to storage: recent searches across sessions |
| Filtering | Server prefix index | Dataset too big to ship | Client filter: a few thousand items or fewer, shipped with the page |
| Focus model | aria-activedescendant | Focus stays in the input, typing never stops | Roving focus into the list: rarely, for complex grid popups |
The user types ab, pauses (a request for ab goes out, slow), then types c and pauses (a request for abc goes out, fast). Neither is cached. The controller uses the ticket check but no abort. What does the list show after both answers arrive, and what's in the cache?
Show answer
The list shows abc's suggestions; the cache holds both ab and abc.
- The
abrequest takes ticket1;latestis1. - The
abcrequest takes ticket2;latestis2. It's fast, comes back first, passesid === latestand is rendered. - The slow
abanswer arrives. It's stored in the cache (the code caches before the check), then fails1 === 2and is dropped, so the screen keepsabc. - If the user now deletes the
c,abis served from the cache with no request. Caching late answers is free value; showing them would be a bug.