Your screen is too small. Please widen your browser window or use a device with a screen width of at least 300px to use this app.
JavaScript is single threaded, so the race is not between threads. It is between requests already in flight, and the one that arrives last wins the render even when it was asked for first. Here is the timeline that causes it, why network throttling cannot reproduce it, and what each fix actually guarantees.
JavaScript runs your code on one thread, which is the reason most frontend developers assume races belong to whoever writes the backend. Two callbacks never execute at the same instant, so nothing can be interrupted halfway through. All of that is true, and none of it helps.
The race is not between threads. It is between requests that are already in flight. You started two, the network is under no obligation to answer them in the order you asked, and each answer runs its own handler when it arrives. The handler that runs last writes to state last, and that is the state the user sees. Ordering by when you sent the requests and ordering by when the answers come back are two different orderings, and only the second one decides what is on screen.
Nothing here is broken in a way a runtime can notice. Both requests were valid, both responses were correct answers to the questions they were given, and both handlers did exactly what they were written to do. The defect exists only in the relationship between them, which is why no tool watching either one on its own will ever point at it.
The shortest example is a search box that fetches on every change. Read the timestamps rather than the code, because the code is correct in isolation and wrong in sequence.
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then(setResults) // whichever lands last wins
}, [query])
// t=0 user types "ab" request A is sent
// t=40 user types "abc" request B is sent
// t=120 B lands results for "abc" are rendered
// t=900 A lands results for "ab" are rendered, and the
// search box still reads "abc"Nine hundred milliseconds is not an exotic number. It is a cold serverless function, a query that missed an index, a corporate VPN, or a phone moving between cells. The second request being faster is not a freak event either, because the two requests are not the same work: a narrower query often has less to do. The bug is not that the network misbehaved, it is that the code assumed an order nobody promised.
The search box is the one everybody recognises, and it is the least damaging of the three because the user is still looking at the field and can retype. The second shape is navigation. A request starts on one screen, the user moves to another before it lands, and the handler writes data belonging to a page nobody is on any more. In a list and detail layout this reads as clicking one row and being shown another.
The third is a write followed by a read. You submit a change and refetch the list to pick it up, while a refetch triggered slightly earlier is still travelling. The older answer lands second, the change vanishes from the screen, and the user submits it again. This one is the most expensive, because the data really did save and the interface is arguing with the truth.
All three are the same defect wearing different clothes, and all three are invisible in development for the same reason.
The instinct is to open the network panel and pick a slow profile. It does not work, and the reason is worth stating plainly: throttling slows every request by roughly the same factor. If A took 900 ms and B took 120 ms, a profile that triples both leaves A at 2700 and B at 360. B still wins. The relative order is exactly what throttling preserves, and the relative order is the entire bug.
What you need is the opposite of uniform. One route has to be slow while another stays fast, on demand, the same way every run. That is a property of the thing answering the calls, not of the connection between you and it, and no setting in a browser can supply it.
This is the quiet reason race conditions survive review. The fix is a few lines and every developer can write it. Confirming that the few lines run on the path that matters requires making the slow answer arrive last on purpose, and most teams have no way to do that, so the cleanup gets written, approved, and never once exercised.
Two of them look interchangeable and are not. One prevents the stale write. The other prevents the stale request. Knowing which you chose matters the moment something else depends on it.
// stops the write. The request still runs to completion.
useEffect(() => {
let cancelled = false
fetch(url)
.then((res) => res.json())
.then((data) => { if (!cancelled) setResults(data) })
return () => { cancelled = true }
}, [query])
// stops the request. Your catch now receives an AbortError that is not
// a failure and must not be rendered as one.
useEffect(() => {
const controller = new AbortController()
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then(setResults)
.catch((err) => { if (err.name !== "AbortError") setError(err) })
return () => controller.abort()
}, [query])The flag is the smaller change and it is enough to fix what the user sees. The request still travels, the server still does the work, and on a list where every keystroke fires one you are paying for answers you have already decided to throw away. AbortController stops the request itself, which is the better default, at the cost of one trap: aborting rejects the promise, so an error handler that has not been taught to recognise AbortError will show a cancellation to the user as a failure.
Beyond those two, a sequence number compared on arrival does the same job when the requests are not tied to a component lifecycle, and a query library keyed by the query itself gives you the behaviour for free. Every one of these is easy. None of them tells you whether it worked.
What all of these are trying to buy is one endpoint that answers slowly while another answers quickly, repeatably. They differ in how far that arrangement reaches beyond the machine it was set up on.
| Approach | What it slows | Same result every run | Reaches teammates and CI | Best at |
|---|---|---|---|---|
| DevTools throttling | All of them, by the same factor | No, it moves with the network | No, one browser on one machine | Seeing a slow app, which is a different bug |
| A setTimeout in your own code | Whichever call you edited | Yes | No, and it ships if you forget to remove it | A one off check while you are already in the file |
| A local mock server | Any route you give a delay | Yes | Only whoever runs the process | A team already keeping one running |
| MSW |
Six reasons a defect this simple reaches production so reliably.
Localhost latency is a millisecond or two and it is the same for every route. The inversion needs one response to overtake another, and nothing on your desk is uneven enough to produce one.
No exception, no rejected promise, no entry in any log. Both requests succeeded. The only evidence is a screen showing data that does not match what the user last asked for.
Two endpoints, two delays, and the slow answer lands last every single run.
| Any handler you add a delay to |
| Yes |
| Whoever runs the project |
| An inversion asserted inside the test suite |
| Fake API | Each endpoint carries its own | Yes | Yes, over HTTPS | The same inversion from a browser, a CI job and a teammate |
The second column is the one that rules out the first row. Everything below it can make a single route slow while its neighbour stays fast, which is the condition the bug needs, and the rows then separate on who else can see it. A reproduction only you can run proves the fix to you. A reproduction a test runner can execute proves it tomorrow, when somebody refactors the hook and quietly removes the cleanup.
Whatever you point the two calls at, the bodies behind them have to differ enough that you can see which one rendered. Mock data covers making test payloads distinguishable, and CORS errors are the other failure that gives your error handler nothing to work with.
It reaches you as sometimes the search shows the wrong results. Nobody can say which keystrokes produced it, because the cause is a difference in timing that the person never saw.
With two requests in flight, whichever finishes first clears the spinner. The interface says it is done while the newer request is still running, so the later overwrite looks like a fresh render.
A request per keystroke means six in flight for a six letter query, and each one is a chance to land out of order. Debouncing narrows the window without ever closing it.
Cleanup written, review passed, ticket closed. Whether it actually runs on the path that matters is a question nobody can answer without making the slow response arrive last on purpose.