Part V β The Craft Β· Lesson 10.15
Autocomplete & Search Bar
In one lineDebounce, cancel, cache, and keyboard-navigate β the most-asked LLD question, and the most detail-rich.
Where you've seen itAny good search box. And the bad ones, where results briefly show matches for "piz" after you finished typing "pizza".
Diagram
DEBOUNCE β one request instead of five
keystrokes p i z z a
β β β β β
t(ms) 0 90 180 260 340 βββββΊ 700ms (idle)
β
no debounce βΌ βΌ βΌ βΌ βΌ 5 requests
300ms debounce βββββββββββββββββββββββββββββββββββΊβΌ 1 requestTHE RACE β why the wrong results appear
request "piz" βββββββββββββββββββββββββββββΊ 420ms βββΊ arrives 2nd
request "pizza" βββββββββββΊ 180ms βββΊ arrives 1st
timeline: pizza results shown βββΊ piz results OVERWRITE them β
FIXES (use both)
1. AbortController: cancel the previous request on each new one
2. Ignore any response whose query β the current input valueKEYBOARD + ARIA CONTRACT (8.2, 8.3)
β / β move the active option (no focus change)
Enter select the active option
Esc close the list, keep the text
Tab close and move on
<input role="combobox" aria-expanded="true"
aria-controls="listbox-1"
aria-activedescendant="opt-3"> β focus STAYS in the input
<ul id="listbox-1" role="listbox">
<li id="opt-3" role="option" aria-selected="true">pizza near me</li>STATE MACHINE
[idle] ββtypeβββΊ [debouncing] ββidle 300msβββΊ [loading]
β β
β ββββββ΄βββββ
β results empty
β β β
βΌ βΌ βΌ
[idle] βββEscββββββββββ [open] [no results]How it works
- Debounce ~300ms on the input, and set a minimum query length (2β3 characters) before firing anything.
- Cancel in-flight requests with
AbortController, and additionally discard stale responses by comparing the query string. - Cache by query (6.9) so backspacing to a previous term is instant and costs no request.
- Wire the full keyboard contract β arrows move
aria-activedescendantwhile focus stays in the input. - Handle every state: idle, debouncing, loading, results, empty, error, and offline. Empty-with-suggestions beats a blank box.
useEffect(() => {
if (query.length < 2) return setResults([]);
const controller = new AbortController();
const timer = setTimeout(async () => {
if (cache.has(query)) return setResults(cache.get(query));
try {
const data = await fetch(`/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }).then((r) => r.json());
cache.set(query, data);
setResults(data);
} catch (e) { if (e.name !== 'AbortError') setError(e); }
}, 300);
return () => { clearTimeout(timer); controller.abort(); }; // both, always
}, [query]);Trade-offs
| Decision | Consequence |
|---|---|
| Debounce 300ms | Fewer requests; slight perceived lag |
| Debounce 150ms | Snappier; roughly double the traffic |
| Throttle instead | Steady request rate; more total requests |
| Client-side filter | Instant, only viable for small datasets |
| Cache per query | Instant backspace; memory to bound |
Also worth mentioning: highlight the matched substring, sanitize before rendering it (3.2/3.9), and track the query for analytics β search terms with no results are the single best product signal you can collect.
Interview angle
"Build an autocomplete. What are the failure modes?"
- The race: an earlier slow request landing after a later fast one β fix with
AbortControllerand a stale-response check. - The traffic: no debounce and no minimum length means a request per keystroke.
- The accessibility: it's a
comboboxwitharia-activedescendant, so focus stays in the input while arrows move the selection β most implementations get this wrong.
Recap
- Debounce, minimum length, abort, and discard stale responses β all four.
- Cache by query so backspace is free.
- It's a combobox: arrows move the active option, focus never leaves the input.