Fixing Race Conditions Debouncing Can't Solve in Search UIs
Learn why debouncing alone can't prevent stale API responses from overwriting fresh UI state, and explore four practical fixes to enforce request ordering.
This article is published in English.
I added a 300-millisecond debounce to a search input on a project last year and considered the issue resolved.
A tester later reported something odd: if they typed quickly, the results would briefly flash to something unrelated before settling down correctly.
The initial reaction was disbelief. A look at the network tab showed the debounce doing exactly what it was supposed to do — one request per pause, no extra noise.
The bug was genuine, though. It just wasn't the bug that had been fixed.
Anyone who has built a search bar has likely lived through the setup for this story. Every keystroke triggers a network call, and the backend ends up overwhelmed.
The usual advice is "just debounce it." You add a short delay, the request volume drops, and the ticket gets closed.
Small decisions like this one accumulate in ways that are easy to underestimate. This particular decision looks minor, but it can cause more damage than it seems.
Here's what a debounce never actually guarantees: it controls when a request fires. It has nothing to say about which response lands first.
What Debouncing Search Bars Actually Fixes
Debouncing a search bar addresses exactly one issue: an excess of outgoing requests.
That's the entire scope of what it does. Ordering is untouched.
Here's precisely how it can go wrong. Assume a 300ms debounce window.
- You type
rea, then stop typing for 300ms. Request A is sent forrea. - You continue typing:
react. After another pause, Request B goes out forreact. - Request A misses the server cache and takes 900ms to resolve.
- Request B hits the cache and returns in 120ms.
- B comes back first, so the UI renders results for
react. - Then, 700ms later, A arrives and overwrites the screen with results for
rea.
The input field reads react, but the list below shows results for rea. That mismatch is the flicker the tester noticed.
Debouncing reduces how often this happens, but it doesn't eliminate the possibility. And "less often" is precisely the kind of defect that stays hidden during development, where the API responds instantly and only one person is testing.
It's unclear whether this behaves identically across every framework and tooling combination.
In React projects using fetch or axios, though, this pattern has shown up consistently, every single time.
This Isn’t Just a Search Bar Problem
Once you recognize this failure mode, you start noticing it in many other places. It's the same underlying bug wearing different outfits:
- Filter dropdowns. A user selects "Shoes," then quickly switches to "Bags." If the Shoes request resolves last, the page ends up displaying shoe listings under a "Bags" label.
- Tabs. Someone clicks "Orders," then immediately clicks "Invoices." If the Orders response is slower, it still wins the race, leaving the wrong data under the correct tab.
- Pagination. Rapidly clicking Next several times in a row can result in page 2's response arriving after page 4's.
- Date pickers on dashboards. Switching the date range twice in quick succession can leave a chart displaying data for the range that was replaced.
Whenever a user can change their intent faster than the backend can respond, this same race condition is lurking.
Real applications can make this even harder to untangle.
Picture an admin dashboard where someone selects:
- Last 30 days
- Then last 7 days
- Then today
At that point, three separate requests could be in flight simultaneously.
Even accurate data becomes the wrong data once it corresponds to an action the user has already moved past.
Why a Bigger Delay Doesn’t Help
The first instinct is usually to increase the debounce window — try 500ms, then maybe 800ms.
That adjustment, made mostly out of habit rather than actual reasoning, did nothing to reduce how often the flicker occurred. It only made the interface feel sluggish. A request that misses cache and takes 900ms will still outlast an 800ms debounce window.
Tweaking a timing value can't solve a problem rooted in sequencing.
This is the moment the real nature of the bug becomes clear.
A debounce is a traffic-control mechanism.
A race condition is a sequencing failure.
Extending how long a traffic light stays red doesn't determine which car reaches the destination first.
Solving that requires a separate rule entirely.
The Real Fix: Only the Latest Request Is Allowed to Update the UI
That single sentence is the whole solution. Several techniques enforce it, ranging from simple to comprehensive.
Fix 1: Give every request a ticket number
This is the easiest version to explain first, because it makes the underlying mechanics obvious.
import { useState, useRef } from "react";
function useSearch() {
const [results, setResults] = useState([]);
const latestRequestId = useRef(0);
async function search(query) {
const myId = ++latestRequestId.current;
const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
// If a newer request started while we were waiting, throw this away.
if (myId === latestRequestId.current) {
setResults(data.results);
}
}
return { results, search };
}
Every outgoing request is assigned a ticket number.
When a response returns, it asks itself: "Do I still hold the most recent ticket?"
If the answer is no, the response gets discarded. The data itself isn't incorrect — it's simply outdated. And outdated data looks exactly the same as wrong data once it's rendered on screen.
Fix 2: Cancel the old request with AbortController
Checking ticket numbers just ignores outdated responses after they arrive. AbortController prevents them from arriving in the first place. Here's the idea in plain JavaScript, without any framework involved:
let controller = null;
async function search(query) {
// Kill whatever is still in flight.
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const data = await res.json();
renderResults(data.results);
} catch (err) {
// An aborted fetch throws. That's expected, not an error.
if (err.name === "AbortError") return;
showError(err);
}
}
One detail that's easy to overlook: a fetch call that gets aborted rejects with an AbortError. Skip handling that, and you'll end up logging a bogus error on every single keystroke.
It's also worth remembering that aborting only stops the browser from waiting on the response — the server might keep processing the request anyway. That's harmless for a search feature, but it becomes more significant when the request involves writing data.
Fix 3: The React way — useEffect cleanup
When your search logic lives inside an effect, React already provides a built-in mechanism for this exact scenario: the cleanup function. This is the approach recommended directly in React's own documentation.
import { useState, useEffect } from "react";
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
const q = debouncedQuery.trim();
if (!q) {
setResults([]);
return;
}
let ignore = false;
const controller = new AbortController();
fetch(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => {
if (!ignore) setResults(data.results);
})
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
});
// Runs when debouncedQuery changes again, or the component unmounts.
return () => {
ignore = true;
controller.abort();
};
}, [debouncedQuery]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</>
);
}
Notice what this pattern buys you automatically. You debounce the value itself, not the function that fires the request.
That means every time the debounced value changes, the cleanup from the previous run fires first, canceling that stale request. Unmounting the component triggers the same cleanup. You don't need refs or manually incremented counters to make it work.
Fix 4: Let a data-fetching library handle it
If your project already depends on TanStack Query (React Query), this ordering guarantee comes for free — no custom logic required.
Whenever the query key changes, the previous query becomes stale, and the library automatically passes your fetch function a signal that aborts it for you.
import { useQuery, keepPreviousData } from "@tanstack/react-query";
function useSearch(debouncedQuery) {
return useQuery({
queryKey: ["search", debouncedQuery],
queryFn: ({ signal }) =>
fetch(`/search?q=${encodeURIComponent(debouncedQuery)}`, { signal })
.then((r) => r.json()),
enabled: debouncedQuery.trim().length > 0,
placeholderData: keepPreviousData, // v5 replacement for keepPreviousData: true
});
}
The real work happens in two places: the signal passed into queryFn, and the placeholderData: keepPreviousData option, which keeps the previous list visible instead of letting the screen go blank while the next batch of results loads.
SWR offers the same behavior through its own keepPreviousData setting.
Small Habits That Made This Much Easier to Get Right
The following are habits worth building into every search implementation. None of them are particularly clever, but each one has prevented a real bug at some point.
1. Always encode the query string. A stray space or an & character inside the search term will break your URL. Wrap the value in encodeURIComponent(query) every time you build the request. A production search box can genuinely fail on an input as ordinary as "R&D".
2. Ignore empty or overly short queries. Trim whitespace from the input and skip firing a request for an empty string. Setting a minimum length, such as two characters, cuts down on wasted requests and reduces the odds of a race in the first place.
3. Keep showing the previous results while a new request loads. Avoid wiping the list on every keystroke. A small spinner next to the input is far less jarring than an empty screen flashing between searches — a slightly outdated list feels better than nothing at all. This is exactly the behavior keepPreviousData provides.
4. Cache results you've already fetched. People frequently delete characters and retype them. A simple Map keyed by the search term ensures that typing rea, then react, then backspacing to rea again doesn't hit the server twice for the same query.
const cache = new Map();
async function searchWithCache(query, signal) {
if (cache.has(query)) return cache.get(query);
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
const data = await res.json();
cache.set(query, data);
return data;
}
5. Have the backend send the query back with the response. Instead of returning a plain array, respond with something like { query: "react", results: [...] }. That lets the client perform one more sanity check — if (data.query !== currentQuery) return; — which acts as a cheap secondary safeguard and makes any bug immediately visible in the network tab.
6. Deliberately recreate the race condition. You're unlikely to notice this bug on localhost against an API that responds in five milliseconds. Introduce an artificial random delay into your mock server and the problem surfaces almost immediately:
app.get("/search", async (req, res) => {
await new Promise((r) => setTimeout(r, Math.random() * 2000));
res.json({ query: req.query.q, results: fakeResults(req.query.q) });
});
Alternatively, open Chrome DevTools, go to Network, enable throttling, choose "Slow 3G," and type quickly. You'll see it fail.
7. Let pressing Enter skip the debounce delay. When a user hits Enter, trigger the search right away — debounce delays are meant for ongoing typing, not for an explicit signal of intent.
8. Always cancel on unmount. If a user navigates away while a search is in flight, the eventual response will try to call setState on a component that no longer exists. The cleanup function shown in Fix 3 already covers this case. If you're using the ticket-number approach instead, make sure the unmount cleanup increments the counter too.
9. Reach for useDeferredValue when filtering locally, not over the network. If you're filtering a list that's already loaded into memory, you don't need debouncing or request cancellation at all. useDeferredValue keeps the typing experience smooth while React handles the filtering work in the background — but it doesn't replace cancellation logic once a server request is involved.
10. Write a single test that targets the race condition directly. Mock two fetch calls and resolve the second one before the first. Assert that the UI ends up displaying the result from the second call. It's a short test, but it will catch a regression that would likely slip past code review otherwise.
If your search bar has ever briefly shown the wrong results before correcting itself, the debounce delay isn't the culprit, and adding more delay won't fix it either.
Debouncing search input is still a good practice — it meaningfully reduces the number of requests you send. It just doesn't solve the entire problem on its own.
What you also need is an explicit ordering rule: only the most recent request is allowed to update the UI. You can enforce that with a request ID, an AbortController, a useEffect cleanup function, or a library that already implements the pattern for you. Any of these approaches works — the important part is choosing one and using it consistently.