Search-as-you-type against a real API: debouncing, aborting stale requests, and keeping results in order
Take a parts catalogue with, say, 80,000 SKUs, searched by warehouse staff on mid-range Android phones over WiFi that drops to a crawl when three people hit the same access point. A search-as-you-type input that queries the server API on every keystroke will, in its first version, have three bugs that show up within an hour of testing: the API gets hammered with a request per character, stale responses overwrite fresh ones, and results from a previous query flicker in while the new ones are 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.
With these three pieces in place, the results on screen match what the user typed, not what they typed two characters ago. That is the whole bar for a search box, and it is surprisingly easy to miss.