Build a Search-as-You-Type Input: Debouncing, Cancellation, Edge Cases

“Build a search-as-you-type input” is one of the most common frontend interview questions. It tests whether you understand debouncing, race conditions, request cancellation, and the accessibility patterns of dynamic results — all in 30 minutes.

Functional requirements

  • User types in an input field
  • Results appear without explicit submit
  • Empty input clears results
  • Loading state visible while waiting
  • Keyboard nav: arrow keys to highlight, enter to select
  • Screen reader friendly

The core architecture

  1. onChange handler captures the input value
  2. Debounce by 200–300ms
  3. Fetch results when debounce fires
  4. Cancel any in-flight request before starting a new one
  5. Update results state when response returns

Debouncing

Naive: setTimeout that clears on each keystroke. Better: use a custom hook or library (lodash.debounce, useDeferredValue from React).

The right delay depends on the API. For fast APIs (<100ms p99): 200ms is comfortable. For slower (300ms+): 350ms.

Race conditions

The user types “ca” then “cat”. The “ca” request is slower and returns after “cat” results are already shown. Without protection, you would overwrite “cat” results with stale “ca” results.

Solutions:

  • AbortController: abort previous fetch when new fetch starts. Native, ideal.
  • Request ID: tag each request with an incrementing ID. Compare on response; only update state if the response is for the latest request.
  • Last-write-wins by timestamp: brittle but works.

Request cancellation example

const abortRef = useRef(null);

const search = async (query) => {
  if (abortRef.current) abortRef.current.abort();
  abortRef.current = new AbortController();
  try {
    const res = await fetch(`/search?q=${query}`, {
      signal: abortRef.current.signal
    });
    const data = await res.json();
    setResults(data);
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
};

Empty state

  • Empty input → clear results immediately, no fetch
  • Whitespace-only input → treat as empty
  • Min length (e.g., 2 characters) before searching

Accessibility

Use ARIA combobox pattern:

  • role="combobox" on the input
  • aria-expanded reflects results visibility
  • aria-controls points to the results list
  • Results list has role="listbox"
  • Each result has role="option" and aria-selected
  • Live region announces “X results found”

Keyboard navigation

  • Down: next result
  • Up: previous result
  • Enter: select highlighted result
  • Escape: clear or close
  • Home/End: first/last result

Common mistakes

  • No debounce — fires a request on every keystroke
  • No cancellation — race conditions display stale results
  • No empty-state handling — fires fetch with empty query
  • No keyboard support — mouse-only is hostile to power users
  • Hardcoded debounce of 500ms — feels laggy

Frequently Asked Questions

What if the API does not support cancellation?

Use the request-ID pattern — tag the request, ignore stale responses. The fetch still runs but is harmless.

Should I cache results?

For repeat queries within a session: yes, useful UX win. Use a small LRU cache. Invalidate cache when filters change.

How do I handle a result list that fits in a dropdown?

Position absolutely below the input. Use focus-trap so keyboard nav stays within the combobox. Close on click-outside.

Scroll to Top