Back to blog
Article

Search-as-you-type against a real API: debounce, abort, stay in order

Search-as-you-type against a real API: debounce, abort, stay in order
S

StriveBit

4 min readWeb Applications

Search-as-you-type against a real API: debouncing, aborting stale requests, and keeping results in order

A client runs a parts catalog with 80,000 SKUs. Their warehouse staff search on mid-range Android phones over warehouse WiFi that drops to a crawl when three people hit the same access point. We built a search-as-you-type input that queries the server API on every keystroke. The first version had three bugs that showed up within an hour of testing: the API got hammered with a request per character, stale responses overwrote fresh ones, and results from a previous query flickered in while the new ones were loading.

The fixes are straightforward but worth writing down because the wrong order of operations creates subtle races.

**Debouncing the input**

We debounce at 250ms. Shorter and you send too many requests on fast typists; longer and the input feels dead. The debounce lives in a `useEffect` that depends on the query string:

function useDebouncedValue(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

The cleanup clears the pending timeout if the user types another character before the delay expires. This is the part that stops the request flood.

**Aborting stale requests**

Even with debouncing, a user can type "bear", pause, type "bearing", and the first request for "bear" might still be in flight when the second one for "bearing" completes. If the network is uneven, the "bear" response can arrive after the "bearing" response. Without an abort, the user sees results for "bear" when they typed "bearing".

We use an `AbortController` per request and abort the previous one when a new query fires:

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

The cleanup aborts the in-flight request when the effect re-runs. The `catch` ignores `AbortError` because that is expected, not an error condition.

**Keeping results in order**

Aborting solves most races, but on some browsers and network conditions the abort does not fire fast enough — the response can land before the abort takes effect. We add a request ID to guarantee order:

const requestIdRef = useRef(0);

useEffect(() => {
  if (!debouncedQuery) return;
  const currentId = ++requestIdRef.current;
  fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`)
    .then((res) => res.json())
    .then((data) => {
      if (requestIdRef.current === currentId) {
        setResults(data);
      }
    });
  return () => {
    requestIdRef.current++;
  };
}, [debouncedQuery]);

The ref increments on every new request and on cleanup. The response handler checks that the current ref still matches the ID it captured. If a newer request has fired, the ID will not match and the response is discarded.

We use both the abort and the ID check. The abort saves bandwidth and server work; the ID check is the last line of defense against the race that abort does not always catch.

**What we skip**

We do not cache responses on the client. The catalog changes during the day as stock moves, and cached results would show parts that are no longer available. A stale cache is worse than a fresh request. The server adds a `Cache-Control: private, max-age=30` header, which is enough for the back-button case without risking stale inventory.

We do not show a loading spinner on every keystroke. On a mid-range Android phone, the spinner appears and disappears so fast it looks like flicker. We show a spinner only after 400ms of no response, which means the user sees it only when the network is actually slow — the case where it matters.

The search input ships with these three pieces in place. The warehouse team reports that results match what they typed, not what they typed two characters ago.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch