Type "react" into a search box, watch it fetch results, then quickly backspace and type "vue" instead. For half a second, the dropdown shows react results under the word vue. Then it corrects itself, or it doesn't, and either way nobody files a bug report because by the time you'd screenshot it, it's gone.
This happened at a filter UI I worked on — product search, nothing exotic, one fetch() call per keystroke. QA flagged it as "occasionally shows stale results, can't reproduce." That phrase should make you suspicious immediately. "Can't reproduce" usually means "reproduces under conditions I'm not controlling for" — and the condition here was network speed, which nobody controls on purpose.
The fix that made it rarer, not gone
The instinct, reasonably, was to stop firing so many requests:
let timer;
searchInput.addEventListener("input", (e) => {
clearTimeout(timer);
timer = setTimeout(() => runSearch(e.target.value), 250);
});
async function runSearch(query) {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
renderResults(data);
}
Debounce the input, wait 250ms of silence before firing. Fewer requests, fewer chances to collide — the bug got rarer. It did not go away. On a slow connection, or with a server that happens to answer a short query faster than a long one, two debounced requests can still be in flight at once, and they can still finish in the wrong order.
Before you keep reading — what do you think "wrong order" actually means here? It's not that the requests go out of order. It's that they come back out of order, and nothing in the code above checks which one it's looking at before painting it on screen.
The response you render isn't the one you think it is
Here's the part that doesn't show up in a quick read of the code: fetch() makes no promise about finish order. You can send request A, then request B a moment later, and B's response can arrive first — a shorter query, a warmer cache entry, a server that happens to be less loaded for that specific request. Nothing is broken. This is just what concurrent network calls do.
renderResults(data) in the snippet above has no idea which query the data it just received belongs to. It just paints whatever shows up last. If A was "react" and B was "vue", and A's response happens to land after B's, the box ends up showing react results while the input says vue. The bug isn't in your fetch call. It's in the fact that you're treating "the last response that arrived" as if it were "the response to my last request" — and those are only the same thing when nothing is racing.
That's the whole bug, named precisely: it's a race condition — two async operations whose outcome depends on which one happens to finish first, and "happens to" is exactly the word that should worry you. It's not about when things run on the main thread. It's about the gap between starting an operation and acting on its result, and anything else that can also act in that gap.
The fix: stop trusting arrival order, track identity instead
You can't force responses to come back in order — that's a property of the network, not your code. What you can do is make sure that when a response arrives, you check whether it's still the one you care about before you act on it.
The smallest version of that is a counter:
let latestRequestId = 0;
async function runSearch(query) {
const requestId = ++latestRequestId;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
if (requestId !== latestRequestId) {
return; // a newer search started while this one was in flight — drop it
}
renderResults(data);
}
Every call to runSearch claims the next number and remembers it. When its response finally arrives — however late, however out of order — it checks whether it's still the most recent request anyone asked for. If a newer one started in the meantime, this one's result is thrown away, silently, no matter how long it took to get here. The one request that's still "latest" when its answer arrives is the only one allowed to touch the DOM.
Three lines. No library, no state management rewrite. The requests still race each other exactly as before — you've just stopped letting the loser win.
If you'd rather cancel the stale request outright instead of just ignoring its answer, AbortController does that — controller.abort() on the previous request before firing a new one, and a caught AbortError instead of the manual counter check. That trims a network call that would otherwise complete for nothing, which is worth it on anything heavier than a search box. The counter guard above is the version to reach for when you don't control the request layer, or just want the smallest possible fix.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
It's not just search boxes
Once you see this shape, you start noticing it everywhere two async calls can overlap and only one should win:
- Tabs in a dashboard — click "Revenue," then quickly click "Users." If Revenue's data is bigger and arrives after Users', the Users tab briefly shows revenue numbers.
- Infinite scroll with filters — change a filter mid-scroll, and an in-flight page request for the old filter lands after the new one does.
- Autosave — type, pause, type again, pause. Two save requests, and if the first one is slow, it can overwrite the second one's result on the server.
- Optimistic UI rollback — a retry fired after a timeout can resolve after the original request you thought had failed, and now you've "rolled back" a success.
Every one of these has the same fix: attach an identity to the thing you're waiting for, and check it's still current before you act on the answer. React Query and SWR build this in for you automatically — stale responses are dropped before they ever reach your UI, which is a large part of why "just use a data-fetching library" is good advice, not laziness. Redux Toolkit's createAsyncThunk doesn't drop them for you, but it hands you the exact piece the guard above needs: every dispatched thunk carries a unique meta.requestId, so instead of inventing your own counter you compare that id in your reducer. But knowing the three-line version means you're never stuck when you're not using one of those, which is most code that isn't a component's data layer.
Guard the identity, not the order
The lesson generalizes past fetch: whenever you start an async operation and plan to act on its result later, something else can start in the meantime and finish first. You can't make the network, the event loop, or another developer's code behave in request order for you. You can make sure that when a result shows up, you check whether it's still the one you're waiting for before you let it touch anything a user can see.
Go find the oldest fetch() or setTimeout callback in your codebase that fires more than once per user action — a search box, a save button, a polling loop. Does anything check that the response it just got is still the one it wants? If the honest answer is no, you've found a race condition that just hasn't been unlucky yet.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (1)
That QA line — 'occasionally shows stale results, can't reproduce' — is such a tell. I have seen the same thing in a property search filter: someone types fast, the results flicker to the old query for half a second, and by the time anyone looks again it is gone. Framing it as 'guard the identity, not the order' makes the fix stick in my head better than just 'add a debounce.' The autosave example hit home too — a slow first save overwriting a newer one is exactly the kind of quiet data loss nobody notices until later.