Most async bugs announce themselves. A promise rejects and nothing catches it, and you get a console error or a crash. A search-box race condition does the opposite: everything resolves successfully, nothing throws, and the UI just quietly shows the wrong thing for a fraction of a second. That's what makes this particular bug harder to catch than most.
The Setup: Two Requests, One Slower Than Expected
A user types "cal," a request goes out. They keep typing, "calc," a second request goes out. Under normal network conditions, these resolve in the order they were sent, and everything looks fine in every manual test you run on a fast, stable connection. The bug only appears when the first request, for whatever reason, network jitter, server-side load, a slower database query for that specific term, takes longer than the second one and resolves after it.
When that happens, the UI renders the "cal" results after it already rendered the "calc" results, because the response handler for each request doesn't know or care what order the requests were sent in, only what order the responses arrived in. The user is looking at a dropdown that briefly contradicts what they just typed, and it self-corrects a moment later when nothing else happens, which makes it feel like a flicker rather than an obvious bug, and makes it genuinely hard to describe precisely in a bug report.
Why This Is Hard to Catch in Testing
Manual testing on a fast development machine, hitting a local or nearby backend with low, consistent latency, essentially never reproduces this. The requests are fast enough and consistent enough in timing that they almost always resolve in send order. The bug shows up specifically under the conditions that are hardest to reproduce on demand: variable latency, a slower network, a backend under real production load where response times aren't uniform. This is exactly the gap between "works on my machine" and "breaks intermittently for actual users" that makes this class of bug so easy to ship without noticing.
The Two Fixes, and Why Only One Is Reliable
The naive fix some people reach for is a sequence number or timestamp check, tagging each request with an incrementing counter and only rendering a response if its counter is higher than the last one rendered. This works, but it means every response handler needs access to shared mutable state to compare against, and it's easy to get the comparison logic subtly wrong, especially once you add debouncing and caching into the same code path.
The more reliable fix is cancellation: use AbortController to cancel the previous request the moment a new one starts, so a stale request's response either never arrives at all, or arrives as an abort error that the handler explicitly ignores rather than something that needs manual sequence comparison. This eliminates the race condition structurally rather than working around it after the fact, since a canceled request genuinely cannot render its result, there's no comparison logic that can be gotten subtly wrong.
It's Not Limited to Search Boxes
This same failure shape shows up anywhere a component fires a new async request in response to rapidly changing input and renders whatever the most recent response happens to be: a filter dropdown, a typeahead for tagging, even a details panel that reloads when a user quickly clicks through a list of items. Anywhere the trigger for a new fetch can happen faster than the previous fetch can resolve is a candidate for this exact bug, and it's worth specifically checking for it any time you're building a component with that shape, not just autocomplete.
Why This Is Different From a Typical Async Bug
Most async bugs involve a promise that rejects, a callback that never fires, or an error that propagates somewhere unexpected, and all of those leave a trace: a stack trace, a console error, a hung UI state that's visibly wrong. A search race condition leaves none of that. Every request that fires resolves successfully. Every handler runs exactly as written. The only thing wrong is the order two independently-correct operations happened to complete in, which is a category of bug that doesn't show up in a stack trace because nothing actually failed from the code's perspective, it just did the right thing at the wrong time.
This is also why code review rarely catches it. A reviewer reading the search handler in isolation sees correct code: fetch on input change, render on response. The bug only exists in the interaction between two separate invocations of that correct code, which isn't something a single code path review naturally surfaces.
A Version of This Bug in React Specifically
React's useEffect cleanup pattern is the standard fix, but it's worth being explicit about why the naive version without cleanup fails. Consider an effect that fetches based on a query prop and calls setResults in the response handler, with no cleanup function. Every time query changes, a new effect run fires a new fetch, but the previous effect's fetch is still in flight and its .then() callback is still registered. If that older fetch resolves after the newer one, it calls setResults with stale data, overwriting the correct, newer results already on screen. Nothing about this is React-specific in cause, it's the same race condition, but React's re-render cycle is what makes the failure visible to the user immediately, since the stale setResults call triggers a real re-render with the wrong data.
The fix is the cleanup function returned from useEffect, which should set a flag or call .abort() on a controller, so the effect instance that's no longer current can recognize its own response as stale and skip the setResults call entirely rather than trusting whatever data happens to arrive.
A Quick Way to Reproduce It Deliberately
If you want to confirm your code actually handles this correctly rather than just hoping it does, browser devtools network throttling is the fastest way to force the bug to reproduce reliably. Set network throttling to a slow, variable profile, then type quickly into the search box and watch whether stale results ever flash on screen. If they do under throttled conditions, they're happening in production too, just less often and less predictably, which is exactly why it's worth testing under throttling rather than assuming a clean local test means the code is correct. Once you've confirmed the bug reproduces, confirming the fix is just as important, throttle the network again after adding cancellation and verify the flicker is actually gone, not just less frequent.
Further Reading
MDN's guide to using the Fetch API covers the abort signal pattern in more depth, the WHATWG Fetch specification is the underlying reference for exactly how aborting interacts with an in-flight request at the protocol level, and Wikipedia's overview of race conditions covers the broader category of bug this fits into if you want the concept outside the specific context of a fetch call. For the complete pattern search interfaces need, including debouncing and caching alongside cancellation, 137Foundry's guide on autocomplete performance walks through the full implementation.
Top comments (0)