Command Palette

Search for a command to run...

Hectal
PHASE 25Advanced ~28 min· topic 4 of 9

Topic 25.4

Design an Autocomplete / Typeahead

In one line

An autocomplete suggests results while the user types; the design is about sending few requests (debounce), answering repeats from an LRU cache, never showing stale answers (race conditions), a correct ARIA combobox for keyboard and screen readers, and choosing server ranking or client filtering.

Think of it like this

A helpful librarian. You start saying "I'm looking for a book about rea…" and she waits until you pause before walking to the shelf (debounce). If you asked about "react" an hour ago, she remembers the answer instead of walking again (cache). If you change your mind mid-walk, she ignores the book she was fetching for your old question (race conditions). And she points at books in a way everyone can follow, including people who can't see her hand (accessibility).

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Autocomplete / typeahead
A text box that suggests results while you type.
Debounce
Waiting until typing pauses for a moment before doing the work, so a burst of keys causes one request.
Throttle
Doing the work at most once per time window, even while events keep coming.
LRU cache
A fixed-size memory of answers that throws away the one used longest ago when it's full.
Race condition
A bug where the result depends on which of two things finishes first, like an old answer overwriting a new one.
Combobox
An input with a popup list of choices. ARIA gives it a role so screen readers understand it.
aria-activedescendant
An attribute on the focused input naming which option is highlighted, without moving focus.
Live region
An element whose text changes are read out by screen readers, like "5 suggestions".

Step by step

01Requirements and the questions that change the design

How big is the data? (decides client filtering versus server). Personalised? (decides caching across users). What does choosing do? (fill the box, or navigate to a page). Numbers: 8 suggestions, debounce 200 ms, minimum 1 character, suggestions within 200 ms of a pause, cache 50 queries for 5 minutes.

02Architecture

The component is split into a headless hook (state, keyboard, ARIA props) and a data source (a function query → Promise<Item[]>), so the same combobox works for products, people or commands. A cache and a request guard sit between them.

Architecturediagram
Rendering diagram…

03Component tree, data model and interface

SearchBox renders the input and the popup; SuggestionList renders options; Highlight bolds the matching part. The data source contract is tiny, which is what makes it reusable.

autocomplete.tswhole filetsx
type Suggestion = { id: string; label: string; kind: "product" | "category" | "recent"; url?: string };
type Source = (q: string, signal: AbortSignal) => Promise<Suggestion[]>;

// GET /api/suggest?q=rea&limit=8
// -> 200 { "q": "rea", "items": Suggestion[], "complete": false }
// "complete: true" means every match is included, so the client may narrow it locally.

type ComboboxProps = {
  source: Source;
  onSelect: (s: Suggestion) => void;
  renderItem?: (s: Suggestion, query: string) => React.ReactNode;
  debounceMs?: number;   // default 200
  minChars?: number;     // default 1
};

04Deep dive: debounce and races on a timeline

Fast keys collapse into one request. When a slow answer for an old query lands after a fast answer for the new one, the guard drops it (but the cache keeps it).

Deep dive: debounce and races on a timelinediagram
Rendering diagram…

05Deep dive: keyboard and screen readers

Focus never leaves the input. Arrow keys change activeIndex; the input's aria-activedescendant points at the active option's id; that option gets aria-selected="true". Enter chooses it, Escape closes the list. Mouse users get onMouseDown={e => e.preventDefault()} on options so clicking doesn't blur the input before the click lands.

06Deep dive: mobile and performance

On a phone, open a full-screen search sheet so suggestions aren't hidden behind the keyboard. Keep the list short. Render suggestions as plain text (React escapes it, Phase 21.1) and highlight matches by splitting the string, never with dangerouslySetInnerHTML. Prefetch the top suggestion's page on highlight if choosing navigates.

Try it yourself

  1. 1

    Change the debounce

    In the first example, change the debounce from 100 to 300. Predict the console: does rea still get its own request, given the script pauses 150 ms before typing it? Run and check.

  2. 2

    Watch the LRU evict

    Set LIMIT = 1. Predict: is retyping re still a cache hit? Which key is left in the cache at the end?

  3. 3

    Use the combobox yourself

    In the second example, click the input, type a, then use ArrowUp from the start. Where does the highlight go first, and why does the code wrap around? Press Escape and check aria-expanded in the browser's DevTools.

Code & diagrams

Debounced, cached and race-free suggestions New tab

Typing `r`, `re` quickly makes one request. The slow answer for `re` arrives after the fast one for `rea`, so it's ignored for display but kept in the cache, and retyping `re` costs no request at all.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

request: re
request: rea
show rea: React, Read, Ready, Reason
ignored stale answer for re
cache hit: re
requests: 2, cache: rea, re
An accessible combobox: keyboard, aria-activedescendant and a live region New tab

Focus never leaves the input. ArrowDown twice highlights the second match, `aria-activedescendant` names it, a screen reader hears "3 suggestions", and Enter chooses. Try it yourself with the keyboard after the demo.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

options: Banana, Mango, Orange
highlighted: Mango, aria-selected=true
announced: 3 suggestions
selected: Mango
input: Mango, aria-expanded=false
Highlighting matches safely (no innerHTML)tsx
function Highlight({ text, query }: { text: string; query: string }) {
  const i = text.toLowerCase().indexOf(query.toLowerCase());
  if (!query || i < 0) return <>{text}</>;
  return (
    <>
      {text.slice(0, i)}
      <mark>{text.slice(i, i + query.length)}</mark>
      {text.slice(i + query.length)}
    </>
  );
}
// <Highlight text="Ready" query="rea" />  renders  <mark>Rea</mark>dy
// The pieces are text, so React escapes them: a product named "<img onerror>" stays harmless (Phase 21.1).

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Remove the race guard

In the first example, delete the line if (ignore) return console.log("ignored stale answer for " + q);.

terminal
$ Console (and the list under the box, which now shows Redux)
── what you'll see ──
request: re
request: rea
show rea: React, Read, Ready, Reason
show re: React, Read, Ready, Reason, Redux
cache hit: re
requests: 2, cache: rea, re

Myth vs fact

Myth

Debounce alone fixes race conditions.

Fact

Debounce reduces how many requests start, but two requests can still be in flight if the user pauses, types, and pauses again. You still need to ignore or cancel stale answers.

Myth

An autocomplete is accessible if the options are buttons.

Fact

Screen-reader users need the combobox pattern: role="combobox" with aria-expanded, aria-controls and aria-activedescendant, a listbox of options, and keyboard support. Separate buttons make them Tab through every suggestion.

Myth

Client filtering is always faster.

Fact

It is instant once the data is loaded, but shipping 2 MB of products to every visitor costs far more than a few 1 KB suggestion requests. It suits small, stable datasets.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Native <input list> with <datalist> gives a free, accessible, basic autocomplete for small static lists. Its styling and filtering vary by browser, so design systems build a custom combobox, but it's a good answer for an internal tool (see /html).

  • ▸

    Typing in a combobox should never block: if filtering a large local list is slow, keep the input urgent and mark the list update as a transition with useDeferredValue (Phase 16.7). The input stays responsive and INP stays low.

  • ▸

    For server-ranked suggestions, add the query to the cache key and anything that changes the answer (locale, user, filters). A cache keyed only by text can show one user's personalised suggestions to another after a sign-out.

  • ▸

    Log suggestion_shown, suggestion_selected with its position, and "searched without choosing". These metrics tell the ranking team whether the suggestions help, and they're how real teams tune debounce and limit values.

Remember this

  1. 1

    Requirements. Functional: show up to 8–10 suggestions as the user types, highlight the matching part, choose one with mouse, touch or keyboard, show recent searches when the box is empty, and handle no results and errors. Non-functional: suggestions appear within about 100–200 ms after the user pauses, the client sends at most one request per pause (a fast typist shouldn't cause one request per key), works with keyboard and screen readers (the ARIA combobox pattern), works on phones with an on-screen keyboard, and the component is reusable (any data source, any item renderer). Clarify: what is searched (products, people, places), how big the dataset is, whether results are personalised, and what selecting does (fill the box or navigate).

  2. 2

    Debounce means waiting until the input has been quiet for a set time (150–300 ms is typical) before acting. Typing r, re, rea quickly produces one request for rea instead of three. Throttle is different: it acts at most once per period even while input continues, which suits scroll or resize more than typing (Phase 24.5). Add a minimum length (often 1–2 characters) so a single keystroke doesn't fetch the whole catalogue, and trim spaces before comparing queries.

  3. 3

    Caching with LRU. Users often type, delete and retype, so the same queries repeat. A small client cache maps query → results. An LRU (least recently used) cache has a fixed size and, when full, evicts the entry that was used longest ago. In JavaScript a Map remembers insertion order, so an LRU is: on read, delete and re-insert the key (it becomes newest); on write, if the size is over the limit, delete map.keys().next().value (the oldest). Cache entries can also have a short lifetime (a few minutes) so suggestions don't go stale. The server side adds HTTP caching (Cache-Control: max-age=60) and a CDN for popular prefixes (see /system-design).

  4. 4

    Race conditions. Requests don't come back in the order you sent them. If re (many matches, slow) is answered after rea (fast), a naive component shows results for re while the box says rea. Fix it by only accepting the answer for the latest query: an ignore flag set in the effect cleanup, a request counter, or AbortController to cancel the old fetch (Phase 14.7). Cancelling also saves bandwidth. Still store the late answer in the cache: it's a correct answer for re, just not for now.

  5. 5

    The ARIA combobox. The input has role="combobox", aria-expanded (is the list open), aria-controls (the list's id) and aria-autocomplete="list". The list is role="listbox" and each suggestion is role="option" with aria-selected on the highlighted one. Focus stays in the input the whole time; aria-activedescendant on the input names the id of the highlighted option, so screen readers announce it while the user keeps typing. Keys: ArrowDown/ArrowUp move the highlight, Enter chooses, Escape closes (and a second Escape clears), Tab leaves normally. A polite live region can say "5 suggestions" (Phase 18.3 and 18.4, and /html for forms and labels).

  6. 6

    Server ranking versus client filtering, and mobile. For a small, fixed dataset (countries, a few thousand products, your own contacts) ship it once and filter on the client: instant, works offline, no server load. For large or personalised data (a web index, millions of products, people search) the server ranks: popularity, typos, the user's history and location, usually from a prefix index or a search engine. A hybrid fetches results for a short prefix and narrows them on the client while the user keeps typing, but only if the server returned the complete list for that prefix. On mobile the on-screen keyboard covers half the screen, so show fewer suggestions or open a full-screen search view; set inputMode="search", enterKeyHint="search", autoComplete="off", autoCapitalize="none" and spellCheck={false}; make options at least 44 px tall for fingers; and be gentler with requests on slow networks.

Explain it without notes

01

Walk me through the design of a search autocomplete.

02

How do you prevent an old response from overwriting a newer one?

03

How would you implement an LRU cache for suggestions, and why LRU?

04

Which ARIA attributes does a combobox need, and how does keyboard navigation work?

05

When would you filter on the client instead of asking the server?

Practice

01

Make the combobox close and clear on a second Escape: the first Escape closes the list, the second clears the text.

02

Add an AbortController to the first example's effect so stale requests are cancelled, not just ignored.

Trade-offs

  • ↔

    A longer debounce means fewer requests but a laggier feel; a shorter one feels instant but costs more. 150–300 ms is the usual range; measure on real users.

  • ↔

    Cancelling stale requests saves bandwidth but throws away answers the cache could reuse; ignoring them keeps the data. Cancel on slow or metered networks, keep when answers are cheap and often retyped.

  • ↔

    A custom combobox gives full control of design and ranking but you own its accessibility; <datalist> or a tested library combobox is less work and less risk.

Done when you can

  • I can write requirements for an autocomplete with latency numbers.

  • I can implement debounce and explain how it differs from throttle.

  • I can build an LRU cache with a Map.

  • I can prevent stale responses with an ignore flag or AbortController.

  • I can build the ARIA combobox pattern with keyboard support.

  • I can choose between client filtering and server ranking and handle mobile.