cf.completefrontend
My progress
SYSTEM DESIGN · #1

Design an autocomplete

A reusable text box that suggests matches as people type, for product search, a people picker, or a country field. The hard parts are deciding when to ask the server, never showing answers for an older query, caching what was already seen, the combobox pattern for keyboard and screen reader users, a popup that fits any screen, and text input in every language without slowing down typing.

The question

Design an autocomplete component for our design system. As someone types into a text box, a list of suggestions opens under it, and they can pick one with a mouse, a tap, or the keyboard. Think of the search box at the top of a shopping site, or the people picker you use to share a document.

Three teams will use it first: product search over a few million items on a server, a people picker over the company directory, and a country field with a short fixed list. It has to feel instant, never show the wrong results, and work for keyboard and screen reader users in every language we ship. Walk me through how you would build it.

Real-life examples: Google search box, Amazon product search, GitHub's "Go to file", The people picker in Google Docs sharing, Airbnb's location search

Requirements: what to ask before drawing anything

An autocomplete looks like a text box and a list. That's why people underestimate it. Behind the box sit four separate problems: a reusable component API, a network layer that copes with fast typing and slow answers, an accessibility pattern that most developers get wrong, and text handling that has to work for Japanese, Arabic, and French as well as English. In 45 minutes you can go deep on two or three of them, so the first five minutes decide which.

Ask questions whose answers change the design: where the data comes from, what one suggestion looks like, what happens when the user picks one, and how fast it must feel on which devices. The table shows the answer this guide assumes for each question, and which part of the design moves if the interviewer says something else.

QuestionAnswer we assumeWhat it changes
Where does the data come from?Both. A server for products (2 million items) and people (80,000), and small local lists such as 250 countries.The data source must be pluggable. Server data needs debounce, race handling, and a cache; local data needs fast filtering in the browser.
What does one suggestion look like?It depends on the team: a plain query for search, an avatar and a name for people, a photo, a title, and a price for products.Rendering has to be the caller's job, through render props or slots.
What happens on pick?Product search goes to the product page or runs a search. The people picker fills a form field with the person's id.Two modes: free text, where any typed text is a valid answer, and select-only, where only a listed option is.
How fast must it feel?Suggestions within 500 ms of the last keystroke at the 75th percentile, and in the same frame for anything seen before.The debounce numbers, the cache, and a warm connection to the API.
Which devices and input methods?Desktop and phones; mouse, touch, keyboard, screen readers, and input method editors for Chinese, Japanese, and Korean.The ARIA combobox pattern, popup placement above on-screen keyboards, and composition events.
How much traffic?The box is on every page. At peak, about 5,000 keystrokes per second across all users.We can't send one request per keystroke; the request rate is a real cost.
One value or many?One. Picking several people as chips is the tag input problem.Keeps the state small: one chosen item.
A wireframe of the autocomplete open in a browser window. The search box holds lapt and keeps focus. Below it, a popup lists a Recent group with laptop stand, and a Products group with Laptop sleeve, 14 inch, which is highlighted, and Laptop bag, 15 inch. Under the popup, a status line says 3 suggestions. Notes say: focus stays in the input; groups have a role and a name; the highlight is set with aria-activedescendant; the count is read out once, when results settle.shop.example.comlaptRecentlaptop standProductsLaptop sleeve, 14 inchLaptop bag, 15 inchstatus: 3 suggestionsfocus staysin the inputgroups havea role and namehighlight viaactivedescendantread out once,when it settles
Four parts carry the hard decisions: a box that never loses focus, a popup that floats over the page, a highlight that moves without moving focus, and a status line that only screen readers hear.

Functional requirements

  • Show suggestions that match the text as the user types, from a server or from a local list.
  • Let the user move through suggestions and pick one with the keyboard, a mouse, or a tap.
  • Let each team decide how a suggestion looks and where its data comes from, without changing the component.
  • Show useful content on focus before anything is typed: recent searches and popular items.
  • Show clear loading, empty, error, and offline states, and never block typing while doing so.
  • Work with both free text and select-only fields, inside or outside a form.

Non-functional requirements, with numbers

Non-functional requirements describe how well the component works, not what it does. Give each one a number, because a number can be tested and a word like "fast" can't.

  • Fast. Suggestions show within 500 ms of the last keystroke at the 75th percentile on a 4G phone. Anything already seen shows in the same frame as the keystroke.
  • Responsive typing. Each keystroke paints within 50 ms on a mid-range phone, so INP, Interaction to Next Paint, stays well under the 200 ms that Google calls good.
  • Correct. The list always belongs to the text in the box. The number of times the list shows results for an older query is zero.
  • Frugal. About one request per pause in typing. Typing "wireless headphones" sends 2 to 4 requests, not 19.
  • Accessible. Meets WCAG 2.2 level AA and follows the WAI-ARIA combobox pattern. Everything works with the keyboard alone, and screen reader users hear the highlighted option and how many results there are.
  • Small. Under about 10 KB of compressed JavaScript, because it ships on every page.
  • International. Works with input method editors, accents, and right-to-left scripts.

Out of scope, said out loud

Naming what you won't design shows judgment and gives the interviewer a chance to redirect you early. For this problem, four things are usually out.

  • The search engine and its ranking. The server decides what matches and in what order. We define the API we need from it.
  • Spelling correction and "Did you mean". They're server features; the component only shows what comes back.
  • Picking many values as chips. That's the tag input, a related but different component.
  • The full search results page that opens after the user presses Enter.

Check your understanding · Exercise 1

Which answers from the interviewer would change this design the most? Select all that apply.

What makes an autocomplete hard

Before choosing anything, name the forces. Each decision later in this guide comes from one of these seven. If you can list them, you can work out most of the design live in the interview instead of reciting it.

  1. People type faster than the network answers. Most people leave 100 to 250 ms between keystrokes inside a word. A suggestion request on a phone takes 100 to 600 ms. So a new keystroke usually arrives before the last answer does.
  2. Answers come back out of order. Each request takes a different path and a different amount of server work. Short queries such as "l" match more items and are often slower to rank than long ones.
  3. Every keystroke is an interaction. INP measures how long the page takes to paint after a key press. Anything the component does on each key adds to it.
  4. Focus stays in the text box. The user moves through the list while still typing, so the usual rule of moving focus to what's active doesn't apply. This is why the ARIA pattern is unusual.
  5. The popup floats over everything. It must escape scroll containers and overflow: hidden parents, fit on screen, and stay visible above a phone's on-screen keyboard.
  6. Text is harder than it looks. Chinese and Japanese users compose words through an input method before they're final. "cafe" should find "Café". Arabic and Hebrew run right to left.
  7. Many teams, many needs. Products, people, and countries have different data, looks, and rules. One component has to serve all of them without turning into 40 props.

The first two forces together cause the most famous autocomplete bug. Watch what happens if the component sends a request on every keystroke and shows whatever answer arrives.

The user types "laptop" in about 800 ms. Six requests go out. They come back in the order the network decides, and the last one to arrive is for "lapto", so the box says "laptop" while the list shows results for "lapto".

A timeline of typing laptop in 800 ms, with a request on every key. Requests for l, la, lap, lapt, lapto, and laptop start at each keystroke and take between 120 and 610 ms. They arrive in the order l, lap, la, lapt, laptop, and lapto. Because lapto arrives last, the box says laptop while the list shows results for lapto.keyslaptopq=l1stq=la3rdq=lap2ndq=lapt4thq=lapto6thq=laptop5th0 ms500 ms1,000 msbox: laptop, list: results for lapto
Each bar is one request, from the keystroke that sent it to its answer. The answer that arrives last wins in a naive component, and here that's the answer for the wrong query.

The rest of the guide takes the forces one at a time: the component API for many teams, when to fetch, stale responses, caching, matching and highlighting, the combobox pattern and keyboard, positioning, long lists, states, international text, and performance. Then we put the parts together.

Think first

Suppose the network always answered in the order requests were sent. Would you still need anything beyond "fetch on every keystroke and show the answer"?

One component, many teams: the API

Start with the API, because every later decision has to fit inside it. The naive version is a component that knows how to search: Autocomplete url="/api/search" labelKey="name". It fetches the URL with the query, reads name from each result, and shows it as text.

Then the teams arrive. The people picker needs an auth header and a POST body. Product search wants a photo and a price in each row. The country field has no URL at all, and analytics wants to know when a suggestion was picked by keyboard.

Each need becomes a prop: headers, method, imageKey, priceKey, items, onPickWithKeyboard. A year later the component has 40 props, half of them interact in ways nobody tested, and the next team still can't do what it needs.

Decision How should teams plug their data and their looks into one autocomplete?

Configuration props

The component does the fetching and the rendering. Teams describe what they want with props: url, params, labelKey, imageKey, and so on.

Pros

  • Very little code for the simplest case.
  • Every instance looks the same, which design teams like.

Cons

  • Every new need is a new prop, so the API grows without end.
  • Can't express auth, request bodies, local lists, or merged sources without more props.
  • Rows can't contain anything the component didn't plan for.

A data source function and render props

The caller passes source(query, { signal }), a function that returns a promise of items, or a plain array for local lists. The caller also passes renderOption(item, state), which returns the content of one row. The component owns everything else: timing, races, caching, keyboard, and ARIA.

Pros

  • Any data source works: REST, GraphQL, a local array, or several merged.
  • Any row content works, while the hard behavior stays in one tested place.
  • The signal lets the component cancel the caller's request.

Cons

  • Callers write a little more code.
  • The component can't see inside source, so it can't, for example, retry a specific HTTP status unless the function throws a typed error.

A headless hook only

The library ships useAutocomplete, which holds the state and returns prop getters such as getInputProps() and getOptionProps(item, index). Teams write all the markup and spread the props onto it.

Pros

  • Total freedom of markup and layout.
  • Behavior and accessibility come from one source of truth.

Cons

  • Every team rebuilds the popup, its placement, and its states, and some will forget part of the ARIA.
  • Looks drift apart across the product.

What we pick: Build the headless hook as the core, and ship the data-source-plus-render-props component on top of it as the default. Most teams use the component and never think about ARIA. A team with an unusual layout, such as a command palette, uses the hook with its own markup and still gets the same timing, races, caching, and keyboard model. Libraries such as Downshift and React Aria are built this way for the same reason.

The autocomplete API in three layers. At the top, your code passes source, renderOption, and onChange as props. In the middle, the Autocomplete component draws the input, popup, listbox, and status region. At the bottom, the useAutocomplete hook holds the state, timing, races, cache, keyboard, and ARIA, and hands prop getters up to the component. On the right, a command palette with its own markup uses the hook directly.Your codesource, renderOption, onChangeAutocompleteinput, popup, listbox, statususeAutocompletestate, timing, races, cache, keys, ARIApropsprop gettersPaletteits own markupcustom layout
Most teams only touch the top layer. A team with an unusual layout skips the middle one and still gets every hard behavior from the hook.

Controlled and uncontrolled, for two values

An autocomplete holds two separate values, and mixing them up causes most API bugs. The input value is the text in the box. The value is the item the user picked, or null.

Typing "lap" changes the input value but not the value. Picking "Laptop stand" sets the value to that product and usually sets the input value to its label.

Each value can be controlled or uncontrolled. Controlled means the parent owns it: it passes value and gets onChange, and the component shows whatever the parent says. Uncontrolled means the component owns it and the parent only gives a starting defaultValue.

Product search controls the input value so it can stay in sync with ?q= in the URL. The people picker controls the value because its form needs the person's id. The country field controls neither. A component must never switch between the two modes during its life, and it should warn in development if a prop goes from undefined to defined.

Events, each with a reason

Events tell the caller what happened and why. The reason matters: an analytics team wants to know if a pick came from the keyboard or a click, and a parent that controls the input value must not treat the text change caused by a pick as new typing, or it will start another search for the label it just chose.

ts
type Source<T> = (query: string, ctx: { signal: AbortSignal }) => Promise<readonly T[]>;

type AutocompleteProps<T> = {
  label: string;
  source: Source<T> | readonly T[];
  getKey: (item: T) => string;
  getLabel: (item: T) => string;
  renderOption?: (item: T, state: { highlighted: boolean; query: string }) => ReactNode;
  renderEmpty?: (query: string) => ReactNode;

  // The picked item: controlled (value) or uncontrolled (defaultValue).
  value?: T | null;
  defaultValue?: T | null;
  onChange?: (item: T | null, reason: "keyboard" | "pointer" | "clear") => void;

  // The typed text: controlled (inputValue) or uncontrolled (defaultInputValue).
  inputValue?: string;
  defaultInputValue?: string;
  onInputChange?: (text: string, reason: "typing" | "pick" | "clear") => void;

  onOpenChange?: (open: boolean) => void;
  freeText?: boolean;  // can the user submit text that isn't an option?
  minChars?: number;   // default 2 for a function source, 0 for an array
  debounceMs?: number; // default 200
};
The public props. A function source fetches; an array source is filtered locally.

Two small choices make the API easier to live with. getKey gives every item a stable id, which React needs for keys and ARIA needs for option ids. getLabel gives the plain text the component uses for matching, for the box after a pick, and for what a screen reader says. Rich rows come from renderOption, but the label is always plain text.

Check your understanding · Exercise 2

The product team wants rows with a photo and a price, and their API needs an auth header. Which parts of our API let them do this without changing the component? Select all that apply.

When to fetch: debounce, throttle, and minimum characters

The naive component sends a request on every input event. Typing "wireless headphones" is 19 keystrokes, so it's 19 requests, and the user reads the results of maybe two of them: one after "wireless" while they glance at the list, and one at the end. Across the site, 5,000 keystrokes per second become 5,000 requests per second. The phone pays too: each request wakes the radio, and each answer is parsed and rendered while the user is still typing.

Two classic tools limit how often a function runs. Throttle runs it at most once per time window while events keep coming: with a 250 ms window, steady typing sends about 4 requests per second. Debounce waits until events stop for a set time, then runs once with the latest value: with a 200 ms wait, it sends nothing while the user types quickly and one request when they pause. Type in the lab below, change the settings, and compare how many requests each strategy would send.

Wait
Keystrokes and the requests four strategies send, with a 200 ms wait. Every key: 0 requests. Throttle: 0. Debounce: 0. Debounce with a minimum length and a 1 second maximum wait: 0.keysevery key0throttle 200 ms0debounce 200 ms0debounce + rules03.0 seconds of typing
Type a search and pause now and then, or play the sample. Each tick is a keystroke; each dot is a request.

Decision When should the autocomplete ask the server for suggestions?

On every keystroke

Each input event starts a request for the current text.

Pros

  • The list updates as fast as the network allows.
  • Nothing to tune.

Cons

  • One request per keystroke: 19 for "wireless headphones".
  • Most answers are thrown away, and the list flickers through partial queries.
  • Makes stale responses very likely.

Throttle

Send at most one request per window, for example 250 ms, with a trailing call so the final text is always sent.

Pros

  • The list keeps updating during long, steady typing.
  • A hard upper bound on requests per second per user.

Cons

  • Still sends several requests while the user is mid-word, for queries nobody reads.
  • Each update re-renders the list while the user types, which costs INP.

Debounce

Restart a timer, for example 200 ms, on every keystroke. When it fires, send one request for the latest text.

Pros

  • Fewest requests: usually one per pause.
  • Requests carry the text the user meant to type, not fragments.

Cons

  • Adds the wait to every answer, even a cached one, unless the cache is checked first.
  • A user who never pauses gets no suggestions at all.

Cache first, then debounce with a maximum wait

On every keystroke, check the cache and show any hit in the same frame. Only a miss goes through a 200 ms debounce. A maxWait of about 1,000 ms forces a request if the user types without a pause for that long.

Pros

  • Seen queries, including Backspace, are instant.
  • Few requests, and never zero for a fast typist.
  • Combines debounce's low cost with throttle's guarantee.

Cons

  • Slightly more code: two timers and a cache lookup on each key.

What we pick: Cache first, then a 200 ms debounce with a 1,000 ms maximum wait. The cache makes the common Backspace and retype cases free and instant, the debounce keeps requests near one per pause, and the maximum wait means a fast typist still sees suggestions within a second.

The numbers, and where they come from

SettingValueWhy
Debounce wait150 to 250 ms; we use 200Most gaps between keys inside a word are 100 to 250 ms. A gap longer than about 200 ms usually means the user is reading or thinking.
Maximum waitAbout 1,000 msA fast typist with no pauses still gets a fresh list every second.
Minimum characters2 for a server source, 1 for Chinese or Japanese, 0 for a local listOne Latin letter matches too much to be useful and is the most expensive query to rank. One Chinese character carries a whole word's meaning.
Network budgetAbout 300 ms at the 75th percentile200 ms of waiting plus 300 ms of network stays inside the 500 ms requirement.
Loading indicator delay300 msMost answers arrive sooner, and a spinner that flashes for 80 ms is noise.

Normalize the text before deciding anything. Trim spaces at both ends, collapse runs of spaces, and lowercase it. Then "lap " and "lap" are the same query, so adding a space doesn't fire a request.

If the normalized query hasn't changed since the last request, do nothing. If it's shorter than the minimum, close the popup or show the initial suggestions instead of asking the server.

ts
function useDebouncedQuery(text: string, waitMs = 200, maxWaitMs = 1000) {
  const [query, setQuery] = useState(text);
  const firstChange = useRef<number | null>(null);

  useEffect(() => {
    const now = performance.now();
    firstChange.current ??= now;
    const waited = now - firstChange.current;
    const delay = Math.max(0, Math.min(waitMs, maxWaitMs - waited));
    const id = setTimeout(() => {
      firstChange.current = null;
      setQuery(text);
    }, delay);
    return () => clearTimeout(id);
  }, [text, waitMs, maxWaitMs]);

  return query;
}
Debounce with a maximum wait. The cleanup clears the timer whenever the text changes again.

Check your understanding · Exercise 3

A user types "iphone" quickly, pauses for 600 ms, then types " case" quickly and stops. With a 200 ms debounce, a 2-character minimum, and an empty cache, which statements are true? Select all that apply.

Stale responses: making sure the list matches the box

Debounce makes races rarer, not impossible. A user types "lap", pauses for 250 ms, so a request goes out, then types "top". A second request goes out for "laptop".

The server is slow for "lap" because it has 40,000 matches to rank, so it takes 600 ms, while "laptop" takes 150 ms. The "laptop" answer arrives first and the list is right. Then the "lap" answer arrives and the naive code shows it. The box says "laptop" and the list shows lap desks and lapel pins.

This is a race condition: the result depends on which of two things finishes first, and nothing controls the order. The naive code has the bug because it trusts every answer: source(query).then(setItems). The fix is to decide, when an answer arrives, whether it still belongs to the current text. Try the lab: send both requests, deliver the answers in either order, and switch the guard.

Guard
A sequence diagram between the autocomplete and the search API, with the guard set to no guard. Nothing sent yet. The box says nothing and the list shows nothing.AutocompleteSearch APIguard:no guardbox: emptylist: empty
Type "lap", then "laptop". Then deliver the two answers in whichever order you like.

Decision How do we stop an old answer from replacing a newer one?

Compare the query on arrival

When an answer arrives, compare the query it was for with the current text. Show it only if they match.

Pros

  • One line of code.
  • Works for any source.

Cons

  • If the user types "lap", then "laptop", then deletes back to "lap", the first, older "lap" answer matches again and may replace a newer one.
  • Wasted requests keep downloading.

Request ids

Keep a counter. Each request takes the next number, and the component remembers the latest one. An answer whose number isn't the latest is dropped.

Pros

  • Exact: only the newest request can ever win.
  • Doesn't depend on the source honoring anything.

Cons

  • Old requests still run to the end and use bandwidth and parsing time.

Cancel with AbortController

Create an AbortController per request and pass its signal to fetch. When a newer request starts, call abort() on the old one. Its fetch rejects with an AbortError.

Pros

  • Stops the download and the JSON parsing of answers nobody needs.
  • Over HTTP/2 the browser can tell the server to stop sending that stream.

Cons

  • A custom source may ignore the signal.
  • If the answer already arrived and is waiting to be handled, abort is too late.

What we pick: Use both cancel and request ids. Abort saves the bandwidth and parsing, and it's the polite thing to do with the caller's request. The id check is what makes the list correct, because it holds even when a source ignores the signal or an answer slips through before the abort. Treat an abort as normal, not as an error: never show an error state for it.

ts
const latest = useRef(0);
const inFlight = useRef<AbortController | null>(null);

async function run(query: string) {
  inFlight.current?.abort();
  const id = ++latest.current;

  const hit = cache.get(key(query));
  if (hit) return dispatch({ type: "success", query, items: hit });

  const controller = new AbortController();
  inFlight.current = controller;
  dispatch({ type: "loading", query });
  try {
    const items = await source(query, { signal: controller.signal });
    cache.set(key(query), items);
    if (id !== latest.current) return; // a newer query took over
    dispatch({ type: "success", query, items });
  } catch (error) {
    if (controller.signal.aborted || id !== latest.current) return; // not an error
    dispatch({ type: "error", query, error });
  }
}
Latest request wins. A cache hit also bumps the id, so a slower network answer can't replace it.

Notice two details in the code. The cache stores the answer even when it's too late to show, because the user may come back to that query with Backspace. And the cache path bumps the id too. Without that, this sequence breaks: "laptop" goes to the network, the user deletes back to "lap", which is cached and shows at once, and then the "laptop" answer arrives and replaces it.

Cancel at the other moments that end a query as well: when the user picks an option, presses Escape, clears the box, or the component unmounts. In React, the effect that starts a request should return a cleanup that aborts it. Since React 18, setting state after unmount no longer warns and does nothing, but the download and the parsing are still wasted work.

Check your understanding · Exercise 4

The component aborts the previous request and also checks request ids. Which situations would show wrong results if it only aborted, with no id check? Select all that apply.

Caching: never ask twice

Watch how people really type. They type "laptop st", see nothing they like, press Backspace three times, and type "laptop ba". Without a cache, "laptop" is fetched again even though it was on screen two seconds ago.

People correct themselves all the time, and popular queries repeat across users all day. A cache turns those repeats into answers that show in the same frame as the keystroke.

There are four places a cache could live. They differ in speed, in who shares them, and in how long they last.

Decision Where should suggestion results be cached?

The HTTP cache only

The API returns Cache-Control: public, max-age=60 on GET /suggest?q=.... The browser and the CDN keep the answers.

Pros

  • No client code at all.
  • The CDN shares popular queries across every user, which protects the server.

Cons

  • The lookup is still an async fetch, so the result never shows in the same frame.
  • Can't reuse a prefix's results for a longer query.
  • Personalized results can't be cached at the CDN.

A Map inside each instance

Each autocomplete keeps a Map from query to items in a ref.

Pros

  • Synchronous and simple.

Cons

  • Lost when the component unmounts, which on many sites is every route change.
  • Grows without a limit during a long session.
  • Two instances on the page don't share anything.

A shared in-memory LRU with a time limit

One module-level cache for the whole page, keyed by source, locale, and normalized query. It holds at most about 100 queries, evicts the least recently used one when full, and treats entries older than 5 minutes as missing.

Pros

  • Synchronous hits that survive remounts and are shared by every instance.
  • Bounded memory.
  • Old results expire, so prices and stock don't drift for long.

Cons

  • Lost on reload.
  • Must be cleared on logout, because results can be personal.

Persisted in IndexedDB

Results are written to IndexedDB and read back on later visits.

Pros

  • Survives reloads and works offline.

Cons

  • Async reads, so no same-frame hits.
  • Results go stale across days, and personal data sits on disk.
  • Far more code than the problem needs.

What we pick: A shared in-memory LRU with a 5 minute time limit, plus HTTP caching at the CDN for public queries. The memory cache gives instant hits for Backspace and repeats, and the CDN takes popular queries off the server. Persist only the user's own recent searches, in localStorage, because those are what the empty box shows on the next visit.

LRU means least recently used: when the cache is full, it drops the entry nobody has read for the longest time. TTL means time to live: each entry has an expiry time, and an expired entry counts as a miss. In JavaScript, a Map remembers insertion order, which makes an LRU about 15 lines. Reading an entry deletes and re-inserts it, so it moves to the newest end. When the map is too big, the first key is the oldest.

ts
class LruCache<V> {
  private map = new Map<string, { value: V; expires: number }>();
  constructor(private max = 100, private ttlMs = 5 * 60_000) {}

  get(key: string): V | undefined {
    const entry = this.map.get(key);
    if (!entry) return undefined;
    this.map.delete(key);
    if (entry.expires < Date.now()) return undefined;
    this.map.set(key, entry); // now the most recently used
    return entry.value;
  }

  set(key: string, value: V) {
    this.map.delete(key);
    this.map.set(key, { value, expires: Date.now() + this.ttlMs });
    const oldest = this.map.keys().next().value;
    if (this.map.size > this.max && oldest !== undefined) this.map.delete(oldest);
  }
}
An LRU cache with a time limit, built on the insertion order of a Map.

Reusing a prefix's results

Every match for "lapt" is also a match for "lap". So if we already have every match for "lap", we can filter them for "lapt" in the browser and skip the request.

The catch is the word "every". The server returns the top 8, not all 40,000 matches, and the best 8 for "lapt" may be matches 9 to 20 for "lap". Filtering the top 8 would hide them.

So the server tells us: each response carries complete: true when it returned every match, which happens when there are fewer matches than the limit. Long, specific queries are often complete: "laptop stand alu" may have only 3 matches. For those, every longer query can be answered locally. When the prefix isn't complete, its filtered results are still useful as a placeholder: show them at once, slightly dimmed, and replace them when the real answer arrives.

The cache lookup on each keystroke, as five steps. First, normalize the text: trim, lowercase, fold accents. Second, if the exact key is cached, show it now. Third, if a complete prefix is cached, filter it locally. Fourth, if the same query is already in flight, share its promise. Fifth, otherwise fetch after the pause, then store the answer in the LRU.1. normalizeno2. exact key?noyesshow it now3. complete prefix?noyesfilter locally4. already in flight?noyesshare the promise5. fetch after pausethenstore in the LRUtrim, lowercase, fold accents
Each step is cheaper than the next, so the component stops at the first one that answers. Only the last step costs a request, and even that one fills the cache for next time.

Store items once, and lists of ids per query

The same product shows up in the answers for "lap", "lapt", "laptop", and "laptop stand". Storing the full item in each entry wastes memory and lets copies disagree: one entry shows the old price.

Instead, normalize. Keep one map of items by id, and let each query entry hold only the ranked list of ids. 100 queries of 8 results each are 800 rows but often only 250 different items. When an item changes, every list that mentions it shows the new version.

What to show before the user types

An empty box on focus is a chance, not a blank. Show the user's recent searches, up to about 5, from localStorage, and a few popular or trending items from an endpoint that the CDN caches for 5 minutes. Fetch the popular items when the box gets focus or when the pointer moves over it, so they're usually ready before the first key. Recent searches are personal: key them by account, cap them at 10, let the user remove one, and clear them on logout.

Check your understanding · Exercise 5

The cache holds an entry for "lap" with 8 items and complete: false. The user types "lapt". What should the component do? Select all that apply.

Matching, ranking, and highlighting safely

Somebody has to decide which items match the text and in what order. For product search that's clearly the server: 2 million products don't fit in a browser. For the country field it's clearly the browser: 250 names are about 6 KB, and a round trip per keystroke would be silly. The people picker sits in between, and it's the interesting case.

Decision Where should matching and ranking happen?

In the browser, over the full list

The page loads the whole list once, then filters and ranks it on every keystroke with no network.

Pros

  • Instant results and no server load.
  • Works offline.
  • No races, no debounce needed.

Cons

  • Only works for small, public lists: roughly under 5,000 items and 100 KB compressed.
  • Ranking can't use signals the browser doesn't have, such as what other users pick.

On the server, per query

Each query goes to a search service that matches, ranks, and returns the top 8 to 10.

Pros

  • Scales to millions of items.
  • Ranking can use popularity, personal history, and permissions.
  • The browser downloads about 2 KB per answer.

Cons

  • Every new query pays a round trip.
  • Needs debounce, race handling, and caching.

Hybrid: a local slice plus the server

Preload a small, likely slice, such as the 200 people the user works with most. Show local matches at once, then merge the server's answer when it arrives, removing duplicates by id.

Pros

  • The most likely answers are instant.
  • The long tail still works.

Cons

  • Merging two ranked lists needs care, so the order doesn't jump when the server answers.
  • Two code paths to test.

What we pick: Let the size and privacy of the data decide, through the same source prop. A small public list is passed as an array and filtered in the browser. A large or permission-filtered set uses a server function. The people picker uses the hybrid: local matches first, server matches appended below them, so nothing the user is looking at moves.

Ranking local matches

"Contains the text" is a poor ranking on its own. For the query "ma", "Guatemala" contains it, but the user almost certainly wants "Madagascar" or "Malaysia". Rank by match quality in tiers, then break ties inside a tier by popularity, then alphabetically with Intl.Collator, so the order is right for the user's language.

TierRuleExample for "ma"
1. ExactThe whole label equals the query.(none)
2. Starts withThe label starts with the query.Madagascar, Malaysia, Mali
3. A word starts withAny word in the label starts with the query.Isle of Man, San Marino
4. ContainsThe query appears anywhere.Guatemala, Panama
5. Fuzzy (optional)Close spelling, such as one typo.(too short to apply; "mlata" would find Malta)

Precompute what you can. Folding each label to lowercase without accents on every keystroke costs time for 5,000 items. Fold them once when the list arrives and keep the folded copy next to the original. Then each keystroke is a cheap pass over prepared strings, about 1 to 2 ms for 5,000 items on a phone.

Highlighting the match without innerHTML

Highlighting the matched part, such as bold "Lap" in "Laptop stand", helps people scan. Many search APIs used to return it as HTML: "label": "<b>Lap</b>top stand". The tempting code is dangerouslySetInnerHTML or innerHTML.

That's an XSS hole, cross-site scripting: labels often come from users or sellers, and a seller can name a product <img src=x onerror=stealCookies()>. If that string is set as HTML, the browser runs it for everyone who types "img".

Keep labels as text from end to end. The server returns the label as plain text plus match ranges, such as matches: [[0, 3]], meaning from character 0 up to, but not including, character 3: "Lap". For local lists, the ranking code produces the ranges itself.

The component splits the label into segments and renders the matched ones inside a mark element. Because React inserts strings as text nodes, a label containing a tag is shown as harmless characters.

tsx
type Range = readonly [start: number, end: number];

function Highlighted({ label, ranges }: { label: string; ranges: readonly Range[] }) {
  const parts: ReactNode[] = [];
  let at = 0;
  for (const [start, end] of ranges) {
    if (start > at) parts.push(label.slice(at, start));
    parts.push(<mark key={start}>{label.slice(start, end)}</mark>);
    at = end;
  }
  if (at < label.length) parts.push(label.slice(at));
  return <>{parts}</>;
}
Split a label into plain and matched segments; React renders both as text.
Two ways to highlight a match. Top: the server sends HTML with a b tag around Lap, the client sets it with innerHTML, and any script inside the label can run. Bottom: the server sends the plain label and the range 0 to 3. The client splits it into a marked segment, Lap, and a plain segment, top stand, and renders both as text nodes, so the label is shown as text.HTML from the server<b>Lap</b>top standinnerHTMLscript can runtext + ranges [[0, 3]]Laptop standtext nodesshown as texta mark element wraps only the matched segment
With ranges, the highlighting is data, not markup. A product named <img onerror=...> is shown as those exact characters instead of running.

Two details are easy to miss. First, the ranges must point into the original label, not the folded copy used for matching. Normalizing "Café" to the NFD form turns "é" into two characters, "e" plus an accent mark, so positions shift. Keep a map from folded positions back to original ones.

Second, decide what to highlight and stay consistent. Product search usually highlights the typed part. Google bolds the completion instead, the part the user hasn't typed yet, because that's what they're scanning for. Either works; mixing them in one product doesn't.

Check your understanding · Exercise 6

A product API returns "label": "<b>Lap</b>top stand". Which ways of showing it are safe? Select all that apply.

The combobox pattern: roles, focus, and announcements

A sighted mouse user sees the list, moves to an option, and clicks. A screen reader user hears only what the markup says. If the list is a div of divs, they hear the text box and nothing else: no list opened, no options, no idea that pressing Down does anything.

The WAI-ARIA Authoring Practices Guide, the APG, defines a pattern for exactly this widget, called the combobox. A combobox is a text box with a popup that helps set its value.

The hard question is where keyboard focus lives while the user moves through options. Focus is the element that receives key presses. Most widgets move focus to whatever is active. Here that breaks typing.

Decision Where should keyboard focus be while the user arrows through the suggestions?

Move real focus to the option

Each option is focusable. Down moves DOM focus from the input to the first option, and so on.

Pros

  • Screen readers announce focused elements very reliably.

Cons

  • Typing stops working: keys go to the option, not the box.
  • On phones, the on-screen keyboard closes when the input loses focus.
  • The caret and the selection in the text are lost.

Keep focus in the input, use aria-activedescendant

DOM focus never leaves the input. The input's aria-activedescendant holds the id of the highlighted option, and screen readers announce that option as if it were focused. This is often called virtual focus.

Pros

  • The user can keep typing at any moment.
  • The phone keyboard stays open.
  • It's what the APG recommends for this pattern.

Cons

  • The highlighted option must exist in the DOM with a stable id.
  • Support differs across screen reader and browser pairs, so it must be tested on each.

Use the native datalist element

An input with a list attribute points at a datalist of option elements. The browser draws and runs the popup.

Pros

  • Zero JavaScript, and the browser draws the popup and exposes it to assistive technology.

Cons

  • Options can only be text: no photos, prices, or groups.
  • Filtering and the popup's look differ by browser and can't be styled.
  • Screen reader and zoom support for the popup is uneven across browsers.
  • No control over timing, loading, or empty states.

What we pick: Keep DOM focus in the input and move a virtual focus with aria-activedescendant. It's the only option that keeps typing, the phone keyboard, and rich rows all working. Native datalist is a fine choice for a simple internal form with text-only options.

The combobox markup. The input has role combobox and aria-expanded true. Its aria-controls points down to a ul with role listbox, which holds three options: q-opt-p1, Laptop stand; q-opt-p2, Laptop sleeve, which is highlighted; and q-opt-p3, Laptop bag. The input's aria-activedescendant points at q-opt-p2, which has aria-selected true. To the right, a separate status region says 3 suggestions.input role=comboboxaria-expanded=truerole=status3 suggestionsul role=listboxq-opt-p1: Laptop standq-opt-p2: Laptop sleeveq-opt-p3: Laptop bagaria-controlsaria-activedescendantaria-selected=trueon the highlight
Focus stays on the input. aria-controls ties it to the list, and aria-activedescendant names the highlighted option by id, so the screen reader reads that option as if it had focus.
html
<label for="q">Search products</label>
<input id="q" type="text" role="combobox"
  aria-autocomplete="list" aria-expanded="true"
  aria-controls="q-list" aria-activedescendant="q-opt-p2"
  autocomplete="off" autocapitalize="none" spellcheck="false" />

<ul id="q-list" role="listbox" aria-label="Suggestions">
  <li id="q-opt-p1" role="option" aria-selected="false">Laptop stand</li>
  <li id="q-opt-p2" role="option" aria-selected="true">Laptop sleeve, 14 inch</li>
  <li id="q-opt-p3" role="option" aria-selected="false">Laptop bag</li>
</ul>

<div role="status" class="sr-only">3 suggestions</div>
The combobox while open, with the second option highlighted.
  • role="combobox" on the input tells assistive technology that this box has a popup. Since ARIA 1.2 the role goes on the input itself; older ARIA 1.1 examples wrap the input in a div with the role, and that structure is now outdated. A combobox's popup is a listbox by default, so aria-haspopup isn't needed.
  • aria-expanded says whether the popup is open. Update it every time; a stale true makes screen readers describe a list that isn't there.
  • aria-controls points at the listbox's id, so the two are linked even if the popup lives elsewhere in the DOM.
  • aria-autocomplete="list" says suggestions appear in a list. Use "both" only if you also complete the text inline, like the grey ghost text some search boxes show.
  • aria-activedescendant names the highlighted option's id, or is removed when nothing is highlighted.
  • Each option has role="option" and a stable, unique id built from the item's key, such as q-opt-p2. The highlighted one has aria-selected="true".
  • autocomplete="off" stops the browser's own form history from opening a second popup on top of ours.

Announcing result counts

When results change, a sighted user sees the list update. A screen reader user hears nothing unless we say it. Add a visually hidden status region, role="status", which screen readers read politely when its text changes, without interrupting. Put messages such as "8 suggestions", "No suggestions for laptpo", or "Couldn't load suggestions" in it.

Restraint matters. If the region changes on every keystroke, the user hears "8 suggestions, 8 suggestions, 7 suggestions" over their own typing. Update it only when the results settle, about 500 ms after the last change, and only when the message is different from the last one.

Keep each option's accessible text complete on its own, such as "Laptop sleeve, 14 inch, £24.99", because that's all the user hears when it's highlighted. Group headings like "Recent" and "Products" go in elements with role="group" and aria-labelledby, so screen readers that support groups can read the name when the highlight enters one.

The full keyboard model

Keyboard users expect the same keys to behave the same way in every combobox. The table follows the APG pattern for a list autocomplete. The one rule underneath all of it: arrow keys move the highlight, every other key edits text.

KeyPopup closedPopup open
Down ArrowOpens it and highlights the first option.Highlights the next option. On the last one, the APG allows staying put or going back to the text, and its own example wraps to the first. Pick one rule for the whole product.
Up ArrowOpens it and highlights the last option.Highlights the previous option. Above the first, the highlight goes back to the text.
Alt + Down ArrowOpens it without highlighting anything.Nothing.
Alt + Up ArrowNothing.Closes the popup and keeps the typed text.
EnterSubmits the typed text, in free text mode.Picks the highlighted option and closes. With nothing highlighted, submits the typed text.
EscapeClears the text, if the product allows it.Closes the popup and keeps the typed text.
TabMoves focus to the next control.Closes the popup and moves on. It doesn't pick anything.
Left, Right, Home, EndMove the caret in the text.Move the caret and clear the highlight.
Page Up, Page DownNothing.Optional: jump 10 options in long lists.
Any characterEdits the text and opens the popup once the minimum length is reached.Edits the text; the highlight resets.

Mouse and touch need two small rules. Moving the pointer over an option highlights it, so mouse and keyboard share one highlight. Clicking an option must not blur the input first: call preventDefault() on the option's mousedown, or the input's blur handler closes the popup before the click lands.

Clicking outside the input and the popup closes it. The lab below is a real combobox: arrow through it and watch the attributes and what a screen reader would say.

The combobox state. The box says nothing. The popup is closed, aria-expanded is false, and aria-activedescendant is not set. A screen reader would say: Search products, combo box. Picked: nothing.box: (empty)popup closedaria-expanded=falseactivedescendant=nonescreen reader hears:Search products, combo boxpicked: nothingwrap: on
Focus never leaves the box. Only aria-activedescendant moves, and the screen reader reads the option it names. Tab and Escape never pick anything.

Check your understanding · Exercise 7

A screen reader user arrows down through the suggestions, but hears nothing. Which bugs could cause it? Select all that apply.

Check your understanding · Exercise 8

The popup is open, nothing is highlighted, and the user has typed "usb c hub" in a free text product search. Which behaviors are right? Select all that apply.

Positioning the popup: edges, layers, and phone keyboards

The naive popup is a position: absolute element right after the input, at top: 100%. It works on a test page. Then the search box goes into a header with overflow: hidden, and the popup is clipped to nothing. In a modal, it's cut off at the modal's edge.

Near the bottom of the screen it opens below the fold. On a phone, the on-screen keyboard covers it entirely. Each of these is a different problem.

Absolute, inside the input's wrapper

The popup sits next to the input in the DOM and is positioned with CSS only.

Pros

  • No JavaScript for placement.
  • Scrolls with the input for free.

Cons

  • Clipped by any ancestor with overflow: hidden or auto.
  • Stacking contexts can hide it under other content, whatever its z-index.
  • Never flips above when there's no room below.

The top layer, positioned to the input

Render the popup with the Popover API, popover="manual", or in a portal at the end of body. A positioning function, such as the one in Floating UI, measures the input and the viewport, then places the popup below or above. CSS anchor positioning can do the same with no script in browsers that support it.

Pros

  • Never clipped, and always above other content.
  • Can flip, shrink, and shift to stay on screen.

Cons

  • Must update on scroll and resize.
  • Click-outside checks must include the popup, which isn't inside the wrapper.

A full-screen sheet on small screens

Below about 600 px wide, focusing the box opens a full-screen search view with the input at the top and results filling the rest.

Pros

  • All the space above the keyboard goes to results.
  • No placement math.

Cons

  • A different layout to build and test.
  • Focus must move into the sheet and back when it closes.

What we pick: The top layer with flip-and-size placement on larger screens, and a full-screen sheet on phones for the main site search. Smaller pickers inside forms keep the anchored popup on phones too, using the visual viewport to stay above the keyboard. The Popover API is Baseline across current browsers, so use it, with a portal as the fallback.

The placement rule fits in a few lines. Measure the space below the input to the bottom of the visible area, and the space above it. If there's less than about 200 px below and more above, open above. Set max-height to the smaller of 320 px and the chosen space minus 8 px, so the popup scrolls internally instead of running off screen.

Make it at least as wide as the input. Then keep the highlighted option visible inside the popup with scrollIntoView({ block: "nearest" }) after each arrow key.

Three placements of the popup, as three small windows. Room below: the popup opens under the search box. Near the bottom: the box sits low, so the popup flips and opens above it. Phone with keyboard: the on-screen keyboard covers the bottom, so the popup's height is limited to the space between the box and the keyboard.room belowsearchopens belowlow on pageflips abovesearchphonesearchfits the gapthe defaulttoo little room belowkeyboard at the bottom
Measure the space above and below against the visible area, not the whole page. On phones the visible area ends at the top of the keyboard.

Phones and on-screen keyboards

On iOS Safari, and on Android Chrome by default, the on-screen keyboard shrinks the visual viewport, the part of the page the user can see, but not the layout viewport that position: fixed and 100vh are based on. So window.innerHeight says there's lots of room below the input when the keyboard covers half of it. Read window.visualViewport.height and offsetTop instead, and recompute when visualViewport fires resize or scroll. Scroll the input near the top of the visible area when it gets focus, so the results get the space between the box and the keyboard.

Updating on every scroll event can itself cost frames. Listen with passive: true, batch the work into one requestAnimationFrame callback, and do all the reads before any writes, so the browser never has to recompute layout in the middle. A ResizeObserver on the input catches width changes that don't come from the window, such as a sidebar opening.

Check your understanding · Exercise 9

The autocomplete is in a header with overflow: hidden, and the popup is clipped. Which fixes work? Select all that apply.

Long result lists: cap, page, or virtualize

A search autocomplete should show few results: 8 to 10 is what Google, Amazon, and most products settle on. More options are slower to scan, slower to render, and further to arrow through. Cap the list on the server with limit, and send people to the full results page with Enter for everything else.

Pickers are different. A time zone field has about 400 options, and a select-only people picker might let you scroll the whole team of 2,000 before typing. Render all 2,000 rows, at about 20 DOM nodes each, and every keystroke that changes the list rebuilds 40,000 nodes. On a mid-range phone that's well over 100 ms, which fails the 50 ms keystroke budget and makes typing stutter.

Virtualization renders only the rows that are visible, plus a few extra above and below, and stands in for the rest with empty space of the right height. A 320 px popup with 40 px rows shows 8 rows, so about 14 rows exist in the DOM whether the list has 50 items or 50,000. Fixed row heights keep the math simple: row i sits at i * 40 px.

A virtualized list of 2,000 options. A spacer stands in for options 1 to 41. Options 42 to 46 are rendered, and option 44 is highlighted. Another spacer stands in for options 47 to 2,000. A note on option 44 says aria-setsize 2000, aria-posinset 44, and its id. A second note says to scroll first, then set the id.spacer: 1 to 41option 42option 43option 44option 45option 46spacer: 47 to 2,000aria-setsize=2000aria-posinset=44id=opt-44scroll first,then set the id
The highlighted option must be in the DOM, so scroll the window to it before pointing aria-activedescendant at it. aria-setsize and aria-posinset let the screen reader say 44 of 2,000.

Virtualization interacts with the combobox pattern in three ways. First, aria-activedescendant must name an element that exists, so when the highlight moves to row 400, scroll the window there before setting the attribute. Second, a screen reader can only count the options that exist, so give each rendered option aria-setsize with the full count and aria-posinset with its position, and the user hears "44 of 2,000". Third, the browser's find-in-page can't find rows that aren't rendered, which is fine inside a popup.

Check your understanding · Exercise 10

A time zone picker has 420 options in a virtualized popup. Which details are needed? Select all that apply.

Loading, empty, error, and offline states

The happy path is one state: results. Real use has seven, counting closed, and each needs to look right and sound right. The naive component has a boolean isLoading and an array. It flashes a spinner for 80 ms on every pause, shows an empty popup when nothing matches, and either swallows errors or replaces the list with a red message that steals the user's attention while they type.

Model the states explicitly as a small state machine: a set of named states and the events that move between them. Then each state has one look and one announcement, and impossible mixes such as "loading and error at once" can't happen.

The autocomplete's states. From closed, focus goes to idle, which shows recent searches, and typing goes to loading. From idle, typing two or more characters goes to loading. From loading, items go to results, zero items go to empty, a failure goes to error, and no network goes to offline. A note says any keystroke returns to loading and Escape returns to closed.closedidleloadingofflineresultsemptyerrorfocus2+ charsitems0 itemsfailsno networktyping: loadingEscape: closed
One state at a time, each with one look and one announcement. Loading keeps the previous results on screen, so the user never sees an empty flash.
StateWhat the user seesWhat a screen reader hears
ClosedJust the box.The label and the typed text.
Idle (open, no query)Recent searches and popular items."5 recent searches" once, when it opens.
LoadingThe previous results, slightly dimmed. A small spinner only after 300 ms.Nothing at first; "Loading" only if it takes over a second.
ResultsUp to 10 options."8 suggestions", once the list settles.
Empty"No results for laptpo", plus a hint such as checking the spelling. Not an option, so the arrows skip it."No suggestions for laptpo".
Error"Couldn't load suggestions." Typing keeps working, and Enter still searches."Couldn't load suggestions", once.
Offline"You're offline" with recent searches and any cached matches."Offline, showing recent searches".

Loading without flicker

Most answers arrive in under 300 ms, so a spinner that appears at once mostly flickers. Keep the previous results on screen while the next ones load, mark the listbox aria-busy="true", and dim it a little. Show a spinner only if the request is still running after 300 ms.

When the new results arrive, swap them in one render. This is the same idea as React's useDeferredValue: show slightly old content instead of nothing.

Errors and retries

Suggestions are a help, not the task. When they fail, the user can still type and press Enter to search, so the error must never block that. Retry once, quietly, after about 1 second for network errors and 5xx responses. If that fails, show a short message in the popup, outside the listbox so the arrows don't land on it, and announce it once.

Typing more naturally retries. For 429 Too Many Requests, respect the Retry-After header and widen the debounce for a while. A 400 means our query was bad, so don't retry it at all.

Offline is a state of its own, not an error: check navigator.onLine and listen for the offline and online events. While offline, skip the network entirely and answer from the cache and recent searches. When the online event fires and the box still has focus, run the current query again. Note that navigator.onLine being true only means there's a network interface, not that the API is reachable, so a failed request on a flaky train is still handled by the error path.

Check your understanding · Exercise 11

Which state designs are right for this autocomplete? Select all that apply.

International text: input methods, accents, and right to left

A Japanese user typing 東京, Tokyo, doesn't press keys for those two characters. They type "toukyou" in Latin letters, and an input method editor, an IME, shows とうきょう with an underline. Pressing Space converts it to candidates such as 東京, and Enter confirms one.

Chinese, Korean, and many other languages work the same way. Until the user confirms, the text is a composition: a draft the IME owns.

The naive component breaks in two ways. It fetches suggestions for every draft, such as "t", "とう", and "とうきょ", which wastes requests and makes the list jump. Worse, it handles the Enter that confirms the IME candidate as "pick the highlighted suggestion", so the user's word is replaced by something they never chose. For hundreds of millions of users, this isn't an edge case: it's the main path.

Browsers report compositions with events. compositionstart fires when a draft begins, compositionupdate as it changes, and compositionend when it's confirmed or cancelled. Every input and keydown event during a draft has isComposing set to true. So: don't schedule fetches while composing, fetch once on compositionend with the confirmed text, and ignore every key while composing.

Safari fires compositionend before the keydown of the confirming Enter, so that keydown has isComposing set to false. Its keyCode is 229, the value browsers use for keys the IME handled, so check that too.

tsx
const composing = useRef(false);

<input
  onCompositionStart={() => {
    composing.current = true;
  }}
  onCompositionEnd={(event) => {
    composing.current = false;
    schedule(event.currentTarget.value); // the confirmed text
  }}
  onChange={(event) => {
    setText(event.target.value); // always show what the IME shows
    if (!composing.current) schedule(event.target.value);
  }}
  onKeyDown={(event) => {
    if (event.nativeEvent.isComposing || event.keyCode === 229) return;
    handleListKeys(event); // arrows, Enter, Escape
  }}
/>
Pause fetching during composition, and never treat the IME's Enter as a pick.
The events while typing 東京 with a Japanese input method. compositionstart: pause fetching. Two input events while composing show とう and とうきょう: no fetch. keydown Space converts the draft to 東京. keydown Enter with keyCode 229 confirms it: the component ignores the key. compositionend with 東京: fetch once.eventbox showswe docompositionstartpause fetchinginput, composingとうno fetchinput, composingとうきょうno fetchkeydown Space東京IME convertskeydown Enter 229東京ignore the keycompositionend東京fetch 東京
Six events, one request. Until compositionend, the text in the box is a draft that belongs to the input method, and so are the keys.

Some products do show suggestions for the draft, because Japanese search engines can match kana. If you do, keep the rule about keys: while composing, arrows and Enter belong to the IME's own candidate window, never to our list.

Accents, case, and what counts as a match

A user in a hurry types "zurich" and expects "Zürich". Fold both sides before comparing: text.normalize("NFD").replace(/\p{M}/gu, "") splits each accented letter into a base letter plus a combining mark, then drops the marks. Lowercase with toLocaleLowerCase(locale), not toLowerCase(), because in Turkish the capital I lowercases to a dotless ı. For sorting and equality, new Intl.Collator(locale, { sensitivity: "base" }) treats "a", "á", and "A" as equal.

Folding isn't right for every language. In Swedish, å, ä, and ö are separate letters at the end of the alphabet, and a Swede typing "a" may not want "Åre" first. That's an argument for doing matching on the server, with language-aware analyzers, for the big data sets, and keeping browser folding for small lists.

Chinese and Japanese have no spaces between words, so the "a word starts with the query" tier doesn't apply, and one character is already a meaningful query, which is why the minimum length there is 1. Send the user's locale with every request and include it in the cache key, because the same text can rank differently per language.

Right-to-left scripts

Arabic and Hebrew run right to left. Set dir="auto" on the input so the browser picks the direction from the first strong character typed, which also handles an English product name typed into an Arabic page. Align the popup to the input's inline start using logical CSS such as inset-inline-start instead of left, so it lines up on the right in a right-to-left page. Give each option dir="auto" too, since mixed results are common.

Up and Down still move the highlight; Left and Right still move the caret, and the browser handles which way that is. Highlight ranges are positions in the string, not on screen, so they stay correct however the text is drawn.

Finally, translate every message, including the ones only screen readers hear, and use plural rules. "1 suggestions" is a bug in English; Arabic has six plural forms. Intl.PluralRules or an ICU message format library picks the right form for each count.

Check your understanding · Exercise 12

A Japanese user types "toukyou", presses Space to convert it to 東京, then presses Enter to confirm. A suggestion is highlighted in our list. What should happen? Select all that apply.

Performance: fast typing and cheap rendering

Every keystroke is an interaction, and INP, Interaction to Next Paint, reports roughly the slowest one a user had on the page. Google counts an INP of 200 ms or less as good, measured at the 75th percentile. For typing, aim much lower: a letter that appears 100 ms after the key feels laggy even if INP is "good". Our budget is 50 ms from key press to the letter on screen, on a mid-range phone whose CPU is 4 to 6 times slower than a developer laptop.

Where does the time go? The key handler and the state update take 1 to 2 ms. Filtering and ranking a local list of 5,000 prepared items takes 1 to 2 ms.

Rendering 10 rich rows, each with an image, highlighted text, and a price, can take 30 to 50 ms on that phone, because React rebuilds them and the browser lays them out. So the list, not the input, is the threat to the budget.

Where one keystroke's time goes on a mid-range phone, for three approaches. Rendering synchronously: the handler takes 2 ms, the list render 45 ms, and the letter and the list both show at 60 ms. Deferring the list: the handler takes 2 ms, the list renders after the paint, the letter shows at 8 ms, and the list at 70 ms. Deferring plus memoized rows: the list render drops to 12 ms, the letter shows at 8 ms, and the list at 25 ms.handlerlist renderletter showslist showssync2 ms45 msat 60 msat 60 msdeferred2 msafter paintat 8 msat 70 ms+ memo rows2 ms12 msat 8 msat 25 msmid-range phone, 10 rich rows
Deferring doesn't make the list faster; it makes the letter stop waiting for it. Memoized rows then cut the list's own cost.

Decision How do we keep typing responsive when rendering the list is slow?

Render everything on every keystroke

The input and the list update in the same synchronous render.

Pros

  • Simple, and the list is never behind the text.

Cons

  • The letter can't paint until the list has rendered: 45 ms or more per key on a phone.
  • Fast typists feel the lag most.

Defer the list's render

Update the input's state as usual, and render the list from useDeferredValue(items) or inside startTransition. React paints the input first, then renders the list at a lower priority and drops that work if another key arrives.

Pros

  • The letter paints in a few milliseconds.
  • Work for lists the user typed past is thrown away instead of finished.

Cons

  • The list can lag the text by a frame or two.
  • Needs React 18 or later.

Filter in a Web Worker

Send the query to a worker that holds the local list. It ranks and posts back the top results.

Pros

  • Filtering never touches the main thread.
  • Suits very large local sets, such as 100,000 contacts.

Cons

  • Copying messages has a cost, and answers are async, so the race guard is needed again.
  • Doesn't help with rendering, which is the bigger cost.

What we pick: Defer the list's render by default. It targets the real cost, rendering, and costs one hook. Use a worker only when a local list is big enough that filtering itself shows up in profiles, which is rare under 20,000 items.

Making each render cheaper

  • Re-render two rows, not ten, on Down Arrow. Memoize each option on its item and its own highlighted flag. Moving the highlight changes exactly two rows: the old one and the new one.
  • Size images up front. Give each thumbnail a fixed width and height so rows don't shift as photos load, and ask the server for a small size, such as 80 by 80, not the product page image.
  • Keep rows light. No tooltips, menus, or heavy components inside options. A row is a label, maybe a picture, and one line of detail.
  • Measure in batches. Position the popup in a requestAnimationFrame callback, never by reading layout inside the key handler right after a state change.
  • Prepare once. Fold and index local labels when the list arrives, not on each keystroke.

Loading cost: bytes and connections

The box is on every page, so its JavaScript is paid on every page, and the whole component should stay under about 10 KB compressed. Server-render the input itself, so it's visible and usable before any script runs. The popup, the cache, and the keyboard logic can load on first focus or pointer hover, since nobody needs suggestions before they reach the box. On focus, also warm the connection: a link rel="preconnect" to the suggestion API's origin saves the DNS, TCP, and TLS setup, often 100 to 300 ms on a phone, on the first request.

Measure in production, not just in the lab. Record INP for keystrokes in the box, the time from the last keystroke to suggestions shown, the cache hit rate, how many responses were dropped as stale, and how often suggestions are picked versus Enter on typed text. A falling pick rate after a change is often the first sign that ranking or timing got worse.

Check your understanding · Exercise 13

On a mid-range phone, typing in the product search feels laggy. Profiles show each keystroke takes 60 ms, 45 of it rendering 10 rows. Which changes help most? Select all that apply.

Putting it together: architecture, data, and the server API

Now that each decision is made, draw the whole thing. The rule behind every boundary: views only show state, one core owns the state, and everything about time and the network sits behind that core. When the list shows something wrong, there's one place to look.

The autocomplete's architecture. The user types into the Input, which reports to useAutocomplete, the core that holds state, keys, and ARIA. The core renders the Listbox and the LiveRegion. Below the core, the Scheduler handles debounce, IME, and minimum length, then the Cache with its LRU and items by id, then on a miss the Requests part with abort and request ids. Requests call the caller's source function outside the component, which calls the Search API behind a CDN.userAutocomplete componentInputuseAutocompletestate, keys, ARIAListboxLiveRegionSchedulerdebounce, IME, min charsCacheLRU + TTL, items by idRequestsabort + request idssource()caller's codeSearch APIbehind a CDNmiss
Views only show state, the core owns it, and everything about time and the network sits in a column behind the core. A cache hit never reaches the network.
PartResponsibilityWhy it's separate
AutocompleteThe default UI: input, popup, listbox, status region. Calls the caller's renderOption.Most teams use only this. It's built entirely on the hook.
useAutocompleteThe state machine: open, text, highlight, value, status. Keyboard handling and ARIA prop getters.Teams with custom layouts reuse it without the default UI.
SchedulerNormalizes the text, applies the minimum length, pauses during IME composition, debounces with a maximum wait.Timing rules change often and are easiest to test alone, with fake timers.
CacheThe shared LRU with a time limit, prefix reuse, items stored once by id.Shared across instances and remounts, so it can't live in one component.
RequestsCalls source, aborts old requests, checks request ids, shares a request already in flight, retries once.All race logic in one place.
PopupTop layer or portal, flip and size, visual viewport, phone sheet.Placement is the same problem as tooltips and menus, so it's shared with them.
Listbox and OptionRows, groups, virtualization, highlight segments.Rendering cost lives here, so this is where memoization goes.
LiveRegionSettled, deduplicated status messages.Announcements need their own timing, separate from rendering.
sourceThe caller's function: REST, GraphQL, or a local array.Data access belongs to each team.

Follow one keystroke through it. The input reports the new text to the core, which updates inputValue at once so the letter paints. The core asks the scheduler whether this text is worth asking about. The scheduler normalizes it and checks the cache, which can answer in the same frame.

On a miss it waits for a pause, then hands the query to the requests part, which aborts the previous request, calls source, and checks the id when the answer comes back. The core moves to the results state, the listbox renders from a deferred copy, and the live region announces the count once the list settles.

Data model: what lives where

Everything here lives in memory except two things. Recent searches are in localStorage, per account. The chosen value lives wherever the caller keeps it: form state for the people picker, the URL's ?q= for product search. None of the cache needs to survive a reload.

ts
type Status = "closed" | "idle" | "loading" | "results" | "empty" | "error" | "offline";

type AutocompleteState<T> = {
  status: Status;
  inputValue: string;        // exactly what is in the box
  query: string;             // the normalized query the items belong to
  items: readonly T[];
  highlightedIndex: number;  // -1 when nothing is highlighted
  value: T | null;           // the picked item
  composing: boolean;        // an IME draft is in progress
};

type CacheEntry = {
  ids: readonly string[];    // ranked item ids for one query
  complete: boolean;         // every match is here, so longer queries can filter locally
};

// Shared by every instance; keys look like "products:en-GB:lapt".
const queries = new LruCache<CacheEntry>(100, 5 * 60_000);
const items = new Map<string, Suggestion>();
The core's state, one per instance, and the cache, one per page.

Keep inputValue and query apart. inputValue is raw and changes on every key, including during IME drafts. query is normalized and only changes when the scheduler decides a search is worth running. The items always belong to query, which is how the component knows whether what it shows matches the box.

The server API

Use a plain GET, so the browser and the CDN can cache it. Return only what a row needs, about 2 KB for 8 results, not full product records. Echo the query back, return match ranges instead of HTML, and say whether the result is complete.

http
GET /api/suggest?q=lapt&limit=8&locale=en-GB

HTTP/2 200
Content-Type: application/json
Cache-Control: public, max-age=60

{
  "query": "lapt",
  "complete": false,
  "items": [
    { "id": "p_1842", "type": "product", "label": "Laptop stand, aluminium",
      "matches": [[0, 4]], "image": "https://img.example.com/1842-80.webp",
      "price": "£24.99" },
    { "id": "q_77", "type": "query", "label": "laptop bag", "matches": [[0, 4]] }
  ]
}
One suggestion request and its answer.
  • limit caps results; the server never sends more than 10.
  • locale changes matching and ranking, so it's part of the URL and the cache key.
  • Personalized results, such as people the user works with, use Cache-Control: private so the CDN never shares them.
  • Errors use one JSON shape, such as { "error": { "code": "rate_limited", "retryAfter": 5 } }, with 429 and a Retry-After header for rate limits.
  • The type field lets one list mix queries, products, and categories, each rendered differently by renderOption.

Check your understanding · Exercise 14

Which statements about this architecture are true? Select all that apply.

Running it in the interview

An autocomplete question tests whether you can see past the obvious. Everyone draws a box and a list. Strong candidates spend their time on the parts that break: timing and races, the combobox pattern, and the component API. Here's a plan for 45 minutes.

A 45 minute plan as a bar. Minutes 0 to 5: ask about requirements. 5 to 12: the API and architecture. 12 to 25: the network, the longest part. 25 to 35: accessibility. 35 to 42: one deeper topic the interviewer picks. 42 to 45: wrap up. Notes say: ask, then write four numbers; race, abort plus ids, LRU cache; roles, keys, status region.askAPInetworka11ydepthend051225354245ask, then writefour numbersrace, abort + ids,LRU cacheroles, keys,status region
The network and accessibility get the most time, because that's where autocomplete answers are won or lost.
  1. Minutes 0 to 5: requirements. Ask about the data source, what a row looks like, free text versus select-only, devices, and languages. Write the four numbers in a corner.
  2. Minutes 5 to 12: API and architecture. Sketch the props: source, renderOption, the two controlled values, the events. Draw the core with the scheduler, cache, and requests behind it.
  3. Minutes 12 to 25: the network. Debounce with numbers, the naive race and its fix with abort plus ids, the LRU cache with prefix reuse. This is where you show depth.
  4. Minutes 25 to 35: accessibility. The combobox markup, aria-activedescendant and why focus stays in the input, the keyboard table, the status region.
  5. Minutes 35 to 42: whatever the interviewer cares about. Positioning, IME, performance, or states. Ask which one they'd like.
  6. Minutes 42 to 45: wrap up. Summarize the decisions and name what you'd measure in production.

Say the failures out loud before the fixes. "If we fetch on every key, typing laptop sends six requests, and the last one to arrive could be for lapto." A concrete failure makes the fix obviously necessary, and it shows you've built one before. The same goes for accessibility: "if focus moved into the list, the phone keyboard would close" is a stronger argument than "the APG says so".

Think first

The interviewer asks: "How would you add inline completion, the grey text that finishes the word as you type?" What changes?

Think first

The interviewer asks: "How would you test this component?" What would you cover?

Check your understanding · Exercise 15

You have 10 minutes left and haven't talked about accessibility at all. Which points give the most value in that time? Select all that apply.

Summary

  • Split the two values: the text in the box and the picked item. Each can be controlled or uncontrolled, and every event says why it fired.
  • Pass data in as a source(query, { signal }) function and rows as renderOption, on top of a headless hook that owns timing, races, caching, keyboard, and ARIA.
  • Check the cache on every keystroke, and send network requests only after a 200 ms pause, with a 1,000 ms maximum wait and a minimum length.
  • Abort old requests and check request ids. Abort saves bandwidth; the id check is what guarantees the list matches the box.
  • Cache in a shared LRU with a time limit, store items once by id, and reuse a prefix's results when the server says they're complete.
  • Keep labels as text with match ranges. Never set suggestion HTML with innerHTML.
  • Keep focus in the input and move a virtual highlight with aria-activedescendant; announce result counts once, through a polite status region.
  • Pause fetching and ignore keys during IME composition, fold accents carefully, and use logical CSS and dir="auto" for right-to-left text.
  • Protect typing first: paint the letter, then render the list from a deferred value.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next