01 — Requirements

“Design an autocomplete search.”

Before writing any code, clarify the scope with your interviewer. The panel on the right shows five capability toggles — each one changes the architecture significantly.

Debounced input? Without debounce, typing “javascript” fires 10 requests — one per keystroke. A 300ms quiet period collapses those into 12 requests. Below 150ms you still spam; above 500ms the UI feels sluggish. Google uses ~200300ms, adjusting by typing speed.

Request cancellation? When the user types "j“, then ”ja“, the response for ”j" is stale. Without AbortController, that stale response can arrive after the “ja” response and overwrite it. This is the classic autocomplete race condition — invisible, intermittent, and infuriating to debug.

Trie caching? A trie (prefix tree) is the natural data structure here because autocomplete queries are prefixes. Each node represents a character; the path from root spells a prefix. Lookup is O(k) where k is prefix length — independent of how many entries the cache holds. A flat Map gives O(1) exact match but cannot answer “which prefixes of this query have cached results?” without scanning every key.

Keyboard navigation? Arrow keys, Enter, Escape — the combobox pattern. WCAG requires all functionality to be keyboard-operable. Without this, screen reader and keyboard-only users cannot use the autocomplete at all.

Match highlighting? When the user searches “rea” and gets “React”, the “Rea” substring should be visually emphasized. The safe approach splits at the match boundary and wraps in a <mark> element. Never use innerHTML — if the query contains <img onerror=alert(1)>, you have an XSS vector.

02 — API Design

Two endpoints cover the entire autocomplete surface:

GET/api/search?q=\{prefix\}&limit=8 — The core search endpoint. Accepts a prefix string, returns ranked results. The limit parameter prevents unbounded result sets. Response time must be under 200ms for the suggestions to feel instant — above 300ms, the user perceives lag.

POST/api/search/log — Analytics endpoint. When a user selects a result, log which item they picked and its position in the list. This data feeds the ranking algorithm — terms that users frequently select for a given prefix should rank higher in future queries.

The type panel shows three data shapes: SearchResult (what the API returns), TrieNode (how the client caches), and AutocompleteState (the React state shape).

03 — Architecture

The component tree follows a clear data flow. Play through each scenario to see how keystrokes flow through the system:

Search flow — Keystroke enters SearchInput, Controller schedules via DebounceTimer, timer fires, checks TrieCache (miss), AbortManager creates a new controller and passes the signal to NetworkLayer, response comes back, gets cached in the trie, results rendered in ResultList.

Cache hit — Same keystroke enters, same debounce wait, but this time the trie lookup succeeds. NetworkLayer is never touched. Zero latency, zero bandwidth.

Race condition — Two queries fire in quick succession. AbortManager cancels the first before the second fires. Without this, the slower first response would overwrite the faster second response — showing "j“ results when the user typed ”ja".

Keyboard navigation — Arrow keys change highlightedIndex, Enter selects, Escape closes. The input retains focus throughout — aria-activedescendant points to the active option without moving DOM focus.

04 — Baseline

Toggle the feature on to see the raw, unoptimized autocomplete. Every single keystroke immediately fires a network request. Type “javascript” and watch: 10 keystrokes, 10 requests.

The comparison below shows the problem in stark terms — without optimization, request count equals keystroke count. With debounce + cache, 10 keystrokes cost exactly 1 request the first time and 0 the second.

The network timeline above will fill with dots rapidly. Each dot is a separate fetch to the server. In production, this would saturate the user's connection and hammer your search infrastructure.

05 — Debounce

Toggle on debounce and watch the timer bar appear below the search input. Each keystroke resets the 300ms countdown. The request only fires when the bar reaches 100% — when the user pauses typing.

Drag the delay slider to feel the tradeoff. At 50ms, you are still sending too many requests — the debounce is too short to absorb fast typing. At 1000ms, the UI feels unresponsive — suggestions appear a full second after the user stops. The sweet spot is 200400ms.

The debounce is implemented with setTimeout + clearTimeout. Each new keystroke clears the previous timer and starts a fresh one. Only when the timer completes undisturbed does the search fire. This is the core insight: debounce converts a stream of rapid events into a single event after the stream ends.

Notice how the metrics bar shows far fewer total requests now. The “Saved” percentage represents network calls avoided by the debounce alone.

06 — AbortController

Toggle on AbortController. Now when you type, each new query aborts the previous in-flight request. Watch the network timeline: aborted requests show as red dots.

The implementation pattern is worth memorizing — it appears in every serious frontend application:

  1. Store a controllerRef via useRef
  2. On each new search: call controllerRef.current?.abort() to cancel previous
  3. Create a new AbortController, store it in the ref
  4. Pass controller.signal to fetch
  5. Catch AbortError silently (it is expected, not an error)

The key insight: AbortController is cooperative. Calling .abort() sets the signal's aborted flag and dispatches an event. The consumer (fetch) checks this flag and rejects. If you do not pass the signal, abort does nothing.

07 — Trie Cache

Toggle on trie caching. Now search for something, clear the input, and search for the same term again. The second time, the network timeline shows a yellow “cached” dot instead of firing a network request.

The trie visualizer below the search shows the tree growing in real time as you search. Each node is a character; nodes with a yellow border have cached results stored at that prefix.

Why a trie instead of a Map? A Map gives O(1) exact-key lookup. But autocomplete queries are prefixes — when the user types “react”, you want to know if “rea” or “re” have cached results that could be filtered client-side. The trie answers this as a natural byproduct of traversal: walk the path r e a c t, and at each node you can check for cached results. A Map would need to iterate every key to find matching prefixes.

The memory cost is modest: 100 cached queries create roughly 400600 nodes. Each node is a character plus a Map of children — trivial for a modern browser.

08 — Keyboard Navigation

Toggle keyboard navigation and try it: type a query, then press to move through results, to go back, Enter to select, Escape to close.

The interaction follows the WAI-ARIA combobox pattern. The critical detail is aria-activedescendant: instead of moving DOM focus to each option (which would blur the input), the input retains focus and the aria-activedescendant attribute points to the ID of the visually highlighted option. Screen readers read the pointed-to element as if it were focused.

This is not a degraded experience — it is a parallel interaction mode. Mouse users click; keyboard users arrow. Both get the same result. The widget below shows the four key bindings and their effects.

09 — Generation Counter

The generation counter is a safety net for a subtle edge case. Even with AbortController, there is a tiny window between calling .abort() and the signal propagating. If a response arrives in that window, it would render stale data.

The fix: increment a counter on each new request, capture its value in the callback closure. When the response arrives, compare the captured value to the current counter. If they differ, the response is stale — discard it.

This pattern — const gen = ++genRef.current; ... if (gen !== genRef.current) return; — is the universal guard against out-of-order async responses. It works regardless of the cancellation mechanism.

10 — Match Highlighting

Toggle highlighting on and type a query. The matching substring in each result is now wrapped in a <mark> element with a colored background.

The implementation splits the result string at the match boundary and wraps the middle slice in a React element. This is the XSS-safe alternative to innerHTML. React elements are never parsed as HTML — they are plain JavaScript objects that React renders safely.

The dangerous alternative — element.innerHTML = text.replace(query, "<b>" + query + "</b>") — is a stored XSS vector. If the query contains <img src=x onerror=alert(1)>, the browser executes the script. The <mark> approach is immune because React escapes all string content by default.

11 — Network Error Handling

Toggle on error simulation and use the sliders to control network delay and error rate. Watch how the system degrades gracefully:

  1. Serve from trie cache if the prefix is cached (zero-cost fallback)
  2. Attempt the network request with configurable delay
  3. Filter parent prefix results client-side if available
  4. Show “suggestions unavailable” while keeping input functional

Crank the error rate to 100% and watch: cached queries still work instantly. The search input never becomes unusable — autocomplete is an enhancement, not a gate. The primary action (submitting a search) must never depend on suggestions loading.

Try different delay values to understand perceived latency. At 100ms, suggestions feel instant. At 1000ms, the user notices. At 2000ms, they have moved on to their next thought.

12 — Full Accessibility

Toggle full ARIA to see the complete combobox attribute set applied to the search input. The widget below shows every ARIA attribute, which element it applies to, and why.

The six critical attributes: role="combobox" on the input, aria-expanded reflecting dropdown state, aria-autocomplete="list" signaling behavior, role="listbox" on the dropdown container, role="option" on each result, and aria-activedescendant pointing to the keyboard-highlighted option.

This is not an afterthought — it is the difference between an autocomplete that works for 95% of users and one that works for everyone. VoiceOver, JAWS, and NVDA all rely on these attributes to announce the autocomplete behavior correctly.

13 — LRU Eviction

The trie grows unboundedly — every unique prefix creates new nodes. In production, this is a memory leak. The fix: LRU (Least Recently Used) eviction.

Adjust the slider to set the maximum cache entries. The pressure meter shows how full the cache is. When usage exceeds the limit, the least recently accessed prefix is evicted.

A typical limit is 200500 cached prefixes. Below that, cache misses increase but memory stays bounded. Above that, memory grows but cache hit rates plateau. The sweet spot depends on your user's search patterns — if they repeat the same 20 queries, a cache of 50 is plenty.

The accessCount field on each trie node tracks usage. On eviction, walk the trie to find the node with the lowest access count and remove its results (not the node itself — it may be an ancestor of other cached prefixes).

14 — Compare Mode

Type in the comparison input below and watch three columns update simultaneously:

No debounce — every keystroke fires. Type “javascript” and see 10 requests.

300ms debounce — only fires after a 300ms pause. Same input produces 12 requests.

Debounce + Cache — first search fires 1 request. Second search for the same term fires 0. The compounding effect of layering optimizations.

This side-by-side comparison makes the cumulative impact visceral. A user who searches 10 times in a session might generate 100 requests without optimization, 10 with debounce, and 5 with debounce + cache — a 95% reduction from two simple techniques.

15 — Scale

How does the system perform with 100 terms vs 100,000? The data structure choice becomes critical at scale.

MapO(1) exact key lookup. Fast for known queries, useless for prefix search. To find “which cached prefixes match my query?”, you scan every key.

TrieO(k) lookup where k is prefix length. Performance is independent of dataset size. Whether the cache holds 100 or 100,000 entries, looking up “react” walks exactly 5 nodes.

Array.filterO(n) scan. At 100 terms, this is fine. At 100,000, you are running 100K string comparisons on every keystroke. With 60fps target, that leaves 16ms per frame — a linear scan of 100K items takes 510ms, consuming half your frame budget.

Drag the slider to see how complexity scaling differs. The trie remains constant relative to query length regardless of dataset size — this is why every serious search infrastructure uses prefix trees internally.

Scope checklist

Scope
Toggle items to define scope
Est. LOC180
Components3
ComplexityLow