Back to Machine Coding
Machine Coding
easy
mid

How would you create a debounced search feature in React?

Delay the search request until typing pauses for ~300ms. Implement with a setTimeout that gets cleared on each keystroke — in React, wrap the value in a `useDebouncedValue` hook and fire the request from a `useEffect` that depends on the debounced value. Cancel inflight requests on change.

5 min read·~15 min to think through

Debounced search is the canonical "don't fire on every keystroke" pattern. Naive onChange → fetch sends one request per character — wasted bandwidth, race conditions, flashing UI. The fix is to wait until the user pauses typing (~250–400ms) before issuing the request.

The core primitive.

ts
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
  let t: ReturnType<typeof setTimeout> | undefined;
  return (...args: Parameters<T>) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

Each call clears the previous timer; only the last call within the window fires.

**In React, debounce the value, not the handler.** A new debounced function on every render breaks the timer:

tsx
function useDebouncedValue<T>(value: T, ms = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), ms);
    return () => clearTimeout(t);
  }, [value, ms]);
  return debounced;
}

function Search() {
  const [query, setQuery] = useState("");
  const debounced = useDebouncedValue(query, 300);

  useEffect(() => {
    if (!debounced) return;
    const ctrl = new AbortController();
    fetch(`/api/search?q=${encodeURIComponent(debounced)}`, { signal: ctrl.signal })
      .then(r => r.json())
      .then(setResults)
      .catch(e => { if (e.name !== "AbortError") console.error(e); });
    return () => ctrl.abort();
  }, [debounced]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

The four details an interviewer is listening for.

  1. Cancel inflight requests. Without AbortController, slow responses for stale queries can land after fresh ones and overwrite the UI. This is the "take-latest" race. Even with debounce, network latency can reorder.
  1. Leading vs trailing. Default is trailing (fire after pause). Sometimes you want leading edge too (fire first call immediately, then suppress until pause) — useful for autocomplete dropdowns where the first letter shows results fast.
  1. Don't debounce the empty string. Hitting the API with q="" returns garbage results or 400s. Bail early.
  1. Don't debounce navigation, only the request. The input value should update instantly (controlled component). Only the effect — the request — is delayed.

Debounce vs throttle. Debounce = wait until quiet. Throttle = at most once per N ms. Search uses debounce. Scroll handlers use throttle.

When debounce is the wrong tool. For autocomplete with very fast backends, prefer useDeferredValue (React 18) — it keeps the input responsive without an arbitrary delay, and uses transition priority. For server-component routers, useTransition around a router push.

Follow-up questions

  • How would you handle race conditions between in-flight requests?
  • Difference between debounce and throttle, with examples?
  • Would you ever debounce on the server side? Why or why not?
  • How does useDeferredValue compare to a debounced value?

Common mistakes

  • Creating a new debounced function on every render — timer never persists.
  • Forgetting to cancel inflight requests, leading to stale results overwriting fresh ones.
  • Debouncing the input value itself, making the field feel laggy.
  • Firing requests for the empty string.

Performance considerations

  • 300ms is the typical sweet spot — under 150ms feels eager, over 500ms feels broken.
  • Pair with request cancellation (AbortController) to avoid wasted server work.
  • For very large result sets, paginate or virtualize the dropdown — debounce alone won't save the render cost.

Edge cases

  • User pastes a long string — debounce still fires once after the pause.
  • User hits Enter before debounce window elapses — flush immediately.
  • Component unmounts mid-flight — abort and clear timer in cleanup.

Real-world examples

  • GitHub repo search, Algolia autocomplete, Linear's command palette, VSCode's quick-open.

Related questions