FrontendMedium30 min

Build an Autocomplete (Typeahead)

A frontend classic that tests debouncing, race conditions, caching, and accessibility.

Say it in 30 seconds
  • 250 msdebounce after the last keystroke
  • 100 entriesclient LRU cache cap, with a 60 s TTL
  • O(prefix length)trie lookup with top-8 completions per node
Show what to say

“I'd debounce input by about 250 ms with a two-character minimum, and abort the stale request on every change so an old answer never overwrites a new one. A small per-query LRU cache with a 60 second TTL makes backspace instant. The UI is an accessible ARIA combobox, and the server uses a top-K trie behind a CDN.”

Trade-offs

  • Longer vs shorter debounce: fewer requests but it feels slower; 200 to 300 ms is the sweet spot.
  • Debounce vs throttle: debounce sends the fewest requests, but nothing updates mid-burst.
  • Trie vs search engine: O(prefix) lookups with no extra infrastructure, but exact prefixes only and a rebuild to update.

Bring it up before they ask

Responses can arrive out of order: debounce cuts requests but two can still be in flight, so abort the older one and ignore any result whose signal was aborted.

0 of 0 sections ticked
30-second answer

Wait for a pause in typing (, about 250 ms), cancel the stale request, and each query so backspace is instant. Use an accessible dropdown, and a prefix tree (trie) on the server.

Functional

  • Suggestions appear as the user types
  • Arrow keys move, Enter selects, Escape closes
  • Selecting fills the input and runs the search
  • Loading, empty and error states
  • Optional: recent searches on focus

Non-functional

  • Feels instant once typing pauses
  • Never shows results for an older query
  • Accessible: the WAI-ARIA combobox pattern
  • Works on flaky networks, on mobile, and with IME input

Out of scope

  • The search results page
  • Spelling correction models

Clarifying questions

Read more · 55 words
  • Where do suggestions come from? A remote API, or a small local list you filter on every key.
  • How many, and what shape? About 8 plain strings, or rich items.
  • Search box or form field? A search box runs the typed text. A form field must pick an option.
  • Keyboard, screen readers, mobile? Always yes.

Answer with RADIO

Structure a frontend answer as Requirements (done), Architecture, Data model, Interface and Optimizations.

Read more · 64 words

Architecture

Play Typing, Arrow keys and Enter and Click an option in the component tree.

  • Typeahead owns the UI: open, active option.
  • useTypeahead owns the data: query, timer, abort, .
  • fetchSuggestions(q, signal) is a prop, so any backend plugs in.

Press Play to watch it run · 10 steps

Typeahead renders a combobox input and a listbox of options, each with a Highlight. All data logic lives in the useTypeahead hook, which calls the fetchSuggestions prop.

Typeahead component treeTypeahead renders a combobox input and a listbox of options, each with a Highlight. All data logic lives in the useTypeahead hook, which calls the fetchSuggestions prop.rendersrenderscallsitems.maplabelactivedescendantTypeaheadopen, activeinputrole=comboboxulrole=listboxuseTypeaheaddebounce, cache, abortli ×8role=optionHighlightmark, no innerHTMLfetchSuggestions(q, signal)GET /api/suggest

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • API gateway
Open in playground
Text description
  1. onChange: the input reports rea; Typeahead calls setQuery, opens the popup and resets the active option.
  2. useTypeahead sees a new query: a cache miss, so it starts the 250 ms timer.
  3. The timer fires: fetchSuggestions('rea', signal).
  4. GET /api/suggest?q=rea&limit=8
  5. 8 items come back.
  6. The hook caches them and sets status to success.
  7. Typeahead re-renders with the new items.
  8. The listbox is shown and the input gets aria-expanded=true.
  9. One option per item, each with a stable id for aria-activedescendant.
  10. Highlight wraps rea in a mark element. React escapes the rest of the label.

Data model

// Data state, in the hook
{ query: 'rea', status: 'idle' | 'loading' | 'success' | 'error', items: [{ id, label }] }

// UI state, in the component
{ open: true, active: -1 }          // -1 = no option highlighted yet

// Client cache: Map<query, { items, at }>, capped at 100 entries, 60 s TTL.
// Map keeps insertion order, so delete + set on access makes the first key the least recently used.

Interface

Optimizations

Read more · 110 words
  • Fewer requests: a 2-character minimum and a 250 ms .
  • Fast: cancel old requests, and keep recent answers for backspace.
  • Safe: show matches with <mark>, never HTML strings.

Do the sum before you pick a backend:

DataLoadUse
5,000 countriesnoneFilter in memory
100,000 productsabout 50 requests/sLIKE 'rea%' on an index
100M past searchesthousands/sTrie, top 8 per node, behind a CDN

A short prefix like "a" matches millions of rows. Ranking them on every key is what breaks LIKE.

<Typeahead fetchSuggestions={(q, signal) => api.suggest(q, signal)} onSelect={item => runSearch(item.label)} label="Search" />

useTypeahead(fetchSuggestions, { delay: 250, minChars: 2, ttlMs: 60_000, maxEntries: 100 })
// → { query, setQuery, status, items }
GET /api/suggest?q=rea&limit=8

200 OK
Cache-Control: public, max-age=300, stale-while-revalidate=3600

{ "q": "rea", "items": [{ "id": "rea:0", "label": "react" }, { "id": "rea:1", "label": "react native" }] }

Architecture diagram

Check the (a small memory of recent answers) before starting the timer.

Read more · 32 words

A hit shows at once, and only misses wait. Play Fast typing, Backspace and Slow response.

Type into the timeline. Every key resets the timer, and only a pause sends a request.

A cache is sticky notes on your monitor: the answers you need most, within arm's reach.

Press Play to watch it run · 8 steps

Each keystroke checks the client LRU first. On a miss, a 250 ms debounce timer starts; when it fires, one abortable fetch goes to the suggest API and the results are cached and rendered.

From keystroke to suggestion listEach keystroke checks the client LRU first. On a miss, a 250 ms debounce timer starts; when it fires, one abortable fetch goes to the suggest API and the results are cached and rendered.queryhitmissafter pauseGET /suggestitemsThe input's onChange calls setQuery.KeystrokeA Map from query to { items, at }, re-inserted on access.LRU cache100 entries, 60 ssetTimeout in the effect; the effect cleanup clears it.Debounce 250mstimer resetsOne controller per query; cleanup calls abort().Abortable fetchAbortControllerGET /api/suggest?q=…&limit=8 with Cache-Control: public, max-age=300.Suggest APItrie, CDN-cachedrole=listbox with up to 8 role=option items.Render listlistbox

Tap any box to see what it is and what it talks to.

  • Client
  • Cache
  • Service
  • API gateway
Open in playground
Text description
  1. You type r. One character is under the 2-character minimum, so nothing is scheduled.
  2. You type e. re is not in the cache…
  3. …so a 250 ms timer starts.
  4. rea, reac and react arrive about 80 ms apart. Each one clears the previous timer and starts a new one.
  5. You pause. 250 ms pass with no keystroke, so the timer fires once, for react.
  6. One request: GET /api/suggest?q=react&limit=8, carrying an AbortSignal.
  7. 8 suggestions come back, often straight from the CDN edge.
  8. The hook stores them in the LRU and renders the list. 5 keystrokes, 1 request.
Interactive

Debounce a search box

Type for real. Each keystroke restarts the timer; only a pause fires a request. Slow responses that a newer request has made obsolete are aborted.

KeystrokesTimerRequests0 s1 s2 s3 s4 s5 s6 s
  • timer reset by a keystroke
  • timer ran out → request fires
  • ✓ response applied
  • ✕ aborted (stale)
Keystrokes
0
Requests fired
0
Aborted (stale)
0
Results shown
–

Type in the box. Each keystroke restarts a 300 ms timer; a request fires only when you pause.

How to approach it

  1. Clarify

    Source, count, keyboard, mobile.

  2. Stabilize input

    about 250 ms, with a minimum length.

  3. Fix races

    Cancel stale requests. Raise this yourself.

  4. Add caching

    A map from query to results, with a .

  5. Accessibility and backend

    ARIA combobox; a prefix index behind a .

Step-by-step answer

  1. Clarify the scope

    Then state the plan: a hook, a combobox, a prefix index.

  2. Debounce the input

    Wait about 250 ms after the last key and skip queries under 2 characters. A fast "react" sends 1 request, not 5. Without it, 10,000 people typing five keys a second can send 50,000 requests a second.

  3. Fix the race

    Pause twice and the older request may answer last. Cancel it in the effect cleanup, and check the signal too, since some servers ignore cancels:

    const items = await load(q, ctrl.signal);
    if (ctrl.signal.aborted) return;                   // a newer query took over
    // …
    return () => { clearTimeout(timer); ctrl.abort(); };   // cleanup: cancel the wait and the request
  4. Cache results

    A Map of query to { items, at }. Drop the oldest past 100 entries and ignore entries older than 60 s. Check it before the timer starts. More in the caching lesson.

  5. Render an accessible combobox

    The input is the combobox, the list is a listbox, each row an option. Arrows move, Enter selects, Escape closes, and focus stays in the input. preventDefault on an option's mousedown stops the blur that would close the list early.

  6. Highlight safely

    Split the label on the escaped query and wrap the matches in <mark>. React escapes all text, so a label like <img onerror=…> stays text.

    'React Native'.split(/(re)/gi)    // ['', 'Re', 'act Native']: matches land at odd indexes
  7. Cover the states

    Loading: keep the old items; show a spinner after about 300 ms. Empty: "No suggestions". Error: stay quiet; typing still works.

  8. Design the backend

    A trie built from query logs, where every node holds its top 8 completions. Lookup costs O(prefix length). Rebuild offline and swap. A absorbs hot prefixes. For fuzzy matching, use a search engine.

Read more · 48 words

Implementation

Read more · 70 words
  • useTypeahead.js: , abort and in one effect.
  • debounce.js: the stand-alone utility, a favourite warm-up.
  • useTypeahead.test.jsx: 3 quick keystrokes send 1 request.
// useTypeahead.js: debounce + abort + LRU/TTL cache, in one effect
import { useEffect, useEffectEvent, useRef, useState } from 'react';

export function useTypeahead(fetchSuggestions, { delay = 250, minChars = 2, ttlMs = 60_000, maxEntries = 100 } = {}) {
  const [query, setQuery] = useState('');
  const [state, setState] = useState({ status: 'idle', items: [] });
  const cache = useRef(new Map());                                     // q -> { items, at }; Map order = LRU order
  const load = useEffectEvent((q, signal) => fetchSuggestions(q, signal));   // always the latest prop (React 19.2)

  useEffect(() => {
    const q = query.trim().toLowerCase();
    if (q.length < minChars) { setState({ status: 'idle', items: [] }); return; }
    const hit = cache.current.get(q);
    if (hit && Date.now() - hit.at < ttlMs) { setState({ status: 'success', items: hit.items }); return; }   // instant: no wait, no request

    const ctrl = new AbortController();
    const timer = setTimeout(async () => {
      setState(s => ({ ...s, status: 'loading' }));                    // keep the previous items visible meanwhile
      try {
        const items = await load(q, ctrl.signal);
        if (ctrl.signal.aborted) return;                              // a newer query took over
        cache.current.delete(q);
        cache.current.set(q, { items, at: Date.now() });              // re-insert = most recently used
        if (cache.current.size > maxEntries) cache.current.delete(cache.current.keys().next().value);
        setState({ status: 'success', items });
      } catch (e) {
        if (e.name !== 'AbortError') setState({ status: 'error', items: [] });
      }
    }, delay);
    return () => { clearTimeout(timer); ctrl.abort(); };              // the next keystroke cancels the wait and the request
  }, [query, delay, minChars, ttlMs, maxEntries]);

  return { query, setQuery, ...state };
}

Failure modes and trade-offs

Read more · 112 words
What breaksEffectMitigation
Answers arrive out of orderAn old query's results flashAbort; ignore aborted results
API downNo suggestionsQuiet error; typing still works
Slow networkFeels laggyKeep old items; spinner after ~300 ms
Labels contain HTMLXSSText nodes; never innerHTML
Input blurs on mousedownClick does nothingpreventDefault on mousedown
IME composition (CJK)Enter picks mid-wordIgnore keys while isComposing
Hot prefixes like "a"Origin overloadCDN caching, in-memory trie

Trade-offs

ChoiceGainCost
Longer debounceFewer requestsFeels slower
Debounce, not throttleFewest requestsNothing updates mid-burst
Client cacheInstant backspaceStale suggestions
Trie, not a search engineFast, no extra systemExact prefixes only

Follow-up questions

How do you highlight the matched text safely?

Split on an escaped regex, wrap matches in <mark>, and let React escape every piece. Never use dangerouslySetInnerHTML.

What happens on a slow or offline network?

Show cached results at once, show loading only after a short delay, and fail quietly.

How would you rank suggestions?

On the server: prefix matches first, then popularity, recency and the user's history. Precompute the top 8 per prefix offline.

What changes on mobile?

Bigger touch targets, a Search key on the keyboard, no hover-only UI, and a slightly longer .

How do you show recent searches?

On focus with an empty box, list recents from localStorage, each removable. Save real searches only, capped at about 10.

The list is local: 5,000 countries. What changes?

Filter in memory on every key, with no debounce. 5,000 comparisons take well under a millisecond.

Check yourself

Question 1 of 3

Score 2 out of 3 or better to mark this page complete.

1. What stops suggestions for an older query from replacing the newer ones?

Patterns behind this

The coding patterns this design is built on. Practise them and the design questions get easier.