Search bar with debounce + async suggestions — how do you architect it
Debounce input (250–300ms), cancel stale requests with AbortController, race-condition guard via request-id or sequence number, cache results by query, render a listbox combobox with ARIA, keyboard nav (↑↓ Enter Esc), and highlight match. Use React Query for caching + request dedup. Test for double-fire on backspace and quick paste.
Search-as-you-type looks simple. It's a magnet for bugs: stale responses, race conditions, leaky requests, and broken keyboard nav.
Anatomy
[ input ] ─ debounce 250ms ─→ fetch(/search?q=…) ─→ Suggestions
│
└ keydown (↑↓ Enter Esc) ─→ navigate listboxDebounce
const debounced = useDebouncedValue(q, 250);
const { data } = useQuery(["search", debounced], ({ signal }) => fetch(...), { enabled: !!debounced });250–300ms feels responsive without over-firing. For very fast typers, 200ms.
Cancellation
async function fetchSuggest(q, { signal }) {
const res = await fetch(`/search?q=${encodeURIComponent(q)}`, { signal });
return res.json();
}React Query passes signal automatically — old requests abort when the query key changes. Without that, you risk: type "ja" then "javascript"; "ja" response arrives last and overwrites "javascript" results — the classic stale-response race.
Manual race guard (if not using React Query)
let seq = 0;
async function search(q) {
const id = ++seq;
const data = await fetch(...);
if (id !== seq) return; // a newer request superseded us
setResults(data);
}Caching
Last 5–10 queries cached. React Query handles this; on its own use a Map with LRU eviction.
Keyboard + ARIA combobox
<input
role="combobox"
aria-expanded="true"
aria-controls="list"
aria-activedescendant="opt-2"
/>
<ul role="listbox" id="list">
<li role="option" id="opt-1" aria-selected="false">…</li>
<li role="option" id="opt-2" aria-selected="true">…</li>
</ul>- ↓/↑ move active option (update aria-activedescendant).
- Enter selects.
- Esc clears + closes.
- Home/End jump to first/last.
- Tab closes and moves focus naturally.
Adopt Radix Combobox / Downshift / cmdk — the WAI-ARIA combobox spec is tricky.
Highlighting
Highlight the matched substring in each suggestion. Compute once on render; don't store HTML in state.
Loading + empty states
- Pending: skeleton or "Searching…".
- Empty after typed: "No results for X."
- Error: "Couldn't load. Retry."
Edge cases
- Empty query — don't fetch.
- Rapid backspace — debounce should collapse; cancellation handles in-flight.
- Paste a long string — same debounce, but no double-fire.
- Trailing whitespace — trim before fetching.
- Min length (e.g. 2 chars) before firing.
- Re-focus + same query — read from cache, don't refetch.
- Mobile keyboard —
inputMode/enterkeyhint="search".
Server-side concerns
- Rate limit per user.
- Index for prefix matching (Postgres trigram, Elastic, Algolia).
- Return relevance + result count.
Interview framing
"Debounce input ~250ms. On each fired query, cancel the previous via AbortController — that single thing eliminates the stale-response bug. React Query handles cancellation, caching, and dedup for free. Combobox UI with proper ARIA — role="combobox" on input, role="listbox" and role="option" on the list, aria-activedescendant updated on ↑↓. Handle empty, loading, error, and 'no results' explicitly. Don't fire on empty query. The bugs I'd test for: stale response overwriting newer, keyboard nav, paste burst, rapid backspace."
Follow-up questions
- •Walk through the stale-response race and the fix.
- •How does AbortController integrate with fetch?
- •Why useDeferredValue might help here?
Common mistakes
- •No cancellation → stale responses overwrite.
- •No debounce → 10 requests per word.
- •Selecting with onClick only (no keyboard).
- •Missing aria-activedescendant.
Performance considerations
- •Debounce + cancel + cache cut request count 10×. Memoize suggestion item component.
Edge cases
- •User pastes 2KB.
- •Network drops mid-request.
- •Same query refocus — should hit cache.
- •i18n input (composition events for IME).
Real-world examples
- •Algolia DocSearch, Linear's cmd-K, GitHub search, VSCode quick-open.