Build an Autocomplete (Typeahead)
A frontend classic that tests debouncing, race conditions, caching, and accessibility.
- 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.
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.
Typeaheadowns the UI: open, active option.useTypeaheadowns the data: query, timer, abort, .fetchSuggestions(q, signal)is a prop, so any backend plugs in.
Tap any box to see what it is and what it talks to.
- Client
- Service
- API gateway
Text description
- onChange: the input reports rea; Typeahead calls setQuery, opens the popup and resets the active option.
- useTypeahead sees a new query: a cache miss, so it starts the 250 ms timer.
- The timer fires: fetchSuggestions('rea', signal).
- GET /api/suggest?q=rea&limit=8
- 8 items come back.
- The hook caches them and sets status to success.
- Typeahead re-renders with the new items.
- The listbox is shown and the input gets aria-expanded=true.
- One option per item, each with a stable id for aria-activedescendant.
- 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:
| Data | Load | Use |
|---|---|---|
| 5,000 countries | none | Filter in memory |
| 100,000 products | about 50 requests/s | LIKE 'rea%' on an index |
| 100M past searches | thousands/s | Trie, 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.
Tap any box to see what it is and what it talks to.
- Client
- Cache
- Service
- API gateway
Text description
- You type r. One character is under the 2-character minimum, so nothing is scheduled.
- You type e. re is not in the cache…
- …so a 250 ms timer starts.
- rea, reac and react arrive about 80 ms apart. Each one clears the previous timer and starts a new one.
- You pause. 250 ms pass with no keystroke, so the timer fires once, for react.
- One request: GET /api/suggest?q=react&limit=8, carrying an AbortSignal.
- 8 suggestions come back, often straight from the CDN edge.
- The hook stores them in the LRU and renders the list. 5 keystrokes, 1 request.
How to approach it
Clarify
Source, count, keyboard, mobile.
Stabilize input
about 250 ms, with a minimum length.
Fix races
Cancel stale requests. Raise this yourself.
Add caching
A map from query to results, with a .
Accessibility and backend
ARIA combobox; a prefix index behind a .
Step-by-step answer
Clarify the scope
Then state the plan: a hook, a combobox, a prefix index.
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.
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 requestCache 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.Render an accessible combobox
The input is the
combobox, the list is alistbox, each row anoption. Arrows move, Enter selects, Escape closes, and focus stays in the input.preventDefaulton an option'smousedownstops the blur that would close the list early.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 indexesCover the states
Loading: keep the old items; show a spinner after about 300 ms. Empty: "No suggestions". Error: stay quiet; typing still works.
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 breaks | Effect | Mitigation |
|---|---|---|
| Answers arrive out of order | An old query's results flash | Abort; ignore aborted results |
| API down | No suggestions | Quiet error; typing still works |
| Slow network | Feels laggy | Keep old items; spinner after ~300 ms |
| Labels contain HTML | XSS | Text nodes; never innerHTML |
| Input blurs on mousedown | Click does nothing | preventDefault on mousedown |
| IME composition (CJK) | Enter picks mid-word | Ignore keys while isComposing |
| Hot prefixes like "a" | Origin overload | CDN caching, in-memory trie |
Trade-offs
| Choice | Gain | Cost |
|---|---|---|
| Longer debounce | Fewer requests | Feels slower |
| Debounce, not throttle | Fewest requests | Nothing updates mid-burst |
| Client cache | Instant backspace | Stale suggestions |
| Trie, not a search engine | Fast, no extra system | Exact 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 3Score 2 out of 3 or better to mark this page complete.
Patterns behind this
The coding patterns this design is built on. Practise them and the design questions get easier.