Skip to content
← All posts

Nov 11, 2026

Your search box fires a request on every keystroke

Every dashboard I have built has a search box. Type "react" into it and open the network tab: five requests, one per keystroke. That is the first bug, and it is the obvious one. The second one is worse, and it took me longer to notice: the request for "re" can finish after the request for "react", and the list quietly shows results for the wrong query. Nobody reports it, because the user just thinks the search is broken in a way they cannot describe.

Requests do not finish in the order they start. The network does not care that "react" is the query the user actually wants. Whichever response lands last wins, and if that is the stale one, your UI is lying.

The fix has three parts, and most tutorials only teach the first one.

One: debounce the value, not the callback. Wait until the user stops typing — around 300ms is a reasonable starting point — and only then fire the request. The clean way to do this in React is a small hook that delays the value itself:

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

Debouncing the value keeps the input responsive — the user types, the characters appear instantly — while the network only hears about it once per pause. Debouncing the callback instead is the version that breaks: a debounced function created once captures stale state, and you end up searching for what the user typed three keystrokes ago.

Two: cancel the request you no longer need. A debounced search still fires a request per pause, and a fast typist pauses more than once. When a new search starts, abort the previous one with AbortController, in the effect cleanup:

useEffect(() => {
  if (!debouncedQuery) return;
  const controller = new AbortController();
  fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, {
    signal: controller.signal,
  })
    .then((res) => res.json())
    .then(setResults)
    .catch((err) => {
      if (err.name !== "AbortError") throw err;
    });
  return () => controller.abort();
}, [debouncedQuery]);

The cleanup runs before the effect runs again, so the old request is cancelled the moment the query changes. And the AbortError check matters: an aborted fetch rejects, and if you treat that rejection as a real failure you will flash an error state for a request you cancelled on purpose.

Three: guard the commit, because cancellation is not a guarantee. Aborting stops you listening, and it usually stops the work — but a response that already arrived, or an endpoint that ignores the signal, can still land. So only the current request is allowed to update state. The abort in the cleanup already gives you most of this, but if you ever fetch outside an effect, keep a request id and ignore anything that is not the latest. The rule is simple: a result is only allowed to render if its query is still the query on screen.

A few honest limits. The 300ms delay is a choice, not a law — a slow, deliberate typist and a fast one want different numbers, and 200–400ms is the range I have seen work. If the list is already in memory, do not hit the server at all: filter locally and skip the whole problem. And do not confuse this with useDeferredValue or useTransition — those keep rendering responsive, but they still run on every keystroke and do nothing for network requests. Debounce is an I/O optimisation, not a rendering one.

The search box is the most-used control on most dashboards I build. It deserves the same care as the table it filters: one request per pause, the old one cancelled, and only the newest answer allowed on screen.