The Search Box That Showed the Wrong Results: Effect Race Conditions
Some bugs are rare enough to ignore and common enough to ship. The fetch race condition is the king of that category: it passes every local test, works in every demo, and then misbehaves for real users on real networks a few times a day, eroding trust in your product one confusing search result at a time.
The anatomy: last response wins, not last request
Nothing about fetch guarantees responses return in request order. Combine that with the naive effect and you get a state machine with a hole in it:
function AssetSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Asset[]>([]);
useEffect(() => {
if (!query) return;
searchAssets(query).then((data) => {
setResults(data); // ❌ whoever resolves LAST writes state,
}); // regardless of which query is CURRENT
}, [query]);
// ...
}
Two renders, two in-flight promises, one results slot. The effect for "brack" was cleaned up long ago, but its promise doesn't know that — it resolves happily and clobbers state.
Tier 1: The ignore flag — minimum viable correctness
The cleanup function runs before the next effect (and at unmount). A boolean in the closure marks the previous request's response as unwelcome:
useEffect(() => {
if (!query) return;
let ignore = false;
searchAssets(query).then((data) => {
if (!ignore) setResults(data); // ✅ stale responses are dropped
});
return () => {
ignore = true;
};
}, [query]);
This is the pattern the React docs themselves bless, and it fixes the correctness bug completely. What it doesn't fix: the stale request still runs to completion, occupying one of the browser's per-origin connection slots and your search cluster's CPU. For a typeahead firing on every keystroke, that's real waste.
Tier 2: AbortController — cancel, don't just ignore
useEffect(() => {
if (!query) return;
const controller = new AbortController();
searchAssets(query, { signal: controller.signal })
.then((data) => setResults(data))
.catch((err) => {
if (err.name === "AbortError") return; // expected — not an error
setError(err);
});
return () => controller.abort();
}, [query]);
Now a superseded request is actually torn down: the browser drops the connection, and a well-behaved backend can stop working on it. Two production notes from shipping this at scale:
- Swallow
AbortErrorand nothing else. The single most common regression I've seen in code review is acatchthat routes aborts into the user-facing error state — every keystroke briefly flashes "Search failed." - Pass the signal all the way down. If
searchAssetsinternally fans out to a suggestions endpoint and a facets endpoint, both need the signal, or you've only half-cancelled.
Tier 3: Stop hand-rolling it
The honest conclusion after fixing this bug for the nth time: request lifecycle management is infrastructure, and infrastructure shouldn't be re-implemented per component. Three structurally different escapes:
A query library. TanStack Query (or SWR) keys the cache by query and only exposes data matching the current key — races are handled by design, plus you get dedup, retries, and caching:
const { data: results = [] } = useQuery({
queryKey: ["assets", query],
queryFn: ({ signal }) => searchAssets(query, { signal }),
enabled: query.length > 0,
placeholderData: keepPreviousData, // no flash of empty results while typing
});
useDeferredValue + Suspense in newer React, where the framework holds back rendering of stale content instead of you policing responses.
Move it off the client entirely. In App Router, a search page keyed by searchParams re-renders on the server per query — the "which response wins" problem disappears because rendering is request-scoped. (You trade it for the caching questions I wrote about earlier this week, so pick your poison consciously.)
For the asset library we landed on TanStack Query with keepPreviousData — the previous results stay visible, slightly dimmed, until fresh ones arrive. Perceived speed went up even though we changed nothing about the backend.
The re-type telemetry signature disappeared the week we shipped the fix. If you have a search box in production and you've never checked for that signature, I'd go look — it's the sound of users silently correcting your race condition for you.
Keep Reading
07 Jul 2026
"Functions Cannot Be Passed to Client Components": Surviving the RSC Boundary
05 Jul 2026
Stale Closures: Why Your WebSocket Handler Sees State From the Past
10 Jul 2026
The Dashboard That Died Every Night: Hunting React Memory Leaks
09 Jul 2026
Maximum Update Depth Exceeded: A Field Guide to Infinite Render Loops