~/writing/async-race-conditions-stale-requests

the bug that only shows up when the network is slow

2026-06-06·8 min read

JavaScriptFrontendReactAsync

A few weeks ago I shipped a search box. Type a query, debounce it, fire a request, render the results. Tested it on localhost, fast wifi, worked great. Then a teammate tried it on a throttled connection and the results were showing data for a query he'd typed and deleted ten seconds ago.

Nothing was "broken" in the sense that any single request failed. Every request succeeded. The bug was in the order they came back.

the setup

Here's roughly what the code looked like:

const [results, setResults] = useState([])
 
const handleSearch = (query) => {
  searchApi.get(query).then(response => {
    setResults(response.data)
  })
}

Looks fine. Type "re", fires a request. Type "rea", fires another. Type "react", fires a third. Three promises, all pending, all racing toward .then().

On localhost they resolve in order because the server is basically instant and network latency is near zero — so request 1 finishes before request 2 even starts. The bug is invisible.

On a real network, latency varies. Request 1 (for "re") might take 800ms because the server has to scan more rows. Request 3 (for "react") might take 150ms because it's a narrower query. So the resolution order is 3, 2, 1 — and whichever .then() runs last wins, overwriting the screen with stale data.

This is the stale request problem, and it's one of the first things that separates "I understand promises" from "I understand what happens when promises meet a real network."

why .then() ordering isn't request ordering

This is the part that doesn't click from tutorials, because tutorials almost always show one request at a time. The moment you have multiple in-flight promises racing each other, you have to stop thinking "first request, then response" and start thinking "multiple independent timers, whichever finishes first wins the race to the callback queue."

Each .then() callback gets pushed onto the microtask queue the instant its promise resolves — not when it was created. JavaScript doesn't know or care that request 1 was sent before request 3. It only knows that request 3's promise resolved first, so request 3's callback runs first.

If your setResults calls don't check "is this still the response I care about," the last one to run wins, regardless of which one the user actually wants.

fix 1: the request id pattern

The simplest fix that scales to basically any situation — keep a counter (or ref) tracking the latest request, and have each response check if it's still the current one:

const latestRequestId = useRef(0)
 
const handleSearch = (query) => {
  const requestId = ++latestRequestId.current
 
  searchApi.get(query).then(response => {
    if (requestId === latestRequestId.current) {
      setResults(response.data)
    }
    // else: a newer request was made, drop this stale response
  })
}

Every call to handleSearch increments the counter and captures its own id in closure. When the response comes back, it checks whether it's still the most recent request. If not, it silently drops itself.

The slow "re" response still comes back eventually, still resolves, still calls .then() — but it checks the id, sees it's stale, and does nothing. No race, no flicker, no wrong data on screen.

fix 2: AbortController

The request id pattern handles the symptom — stale data overwriting fresh data. AbortController handles the cause — it actually cancels the in-flight request, which is useful if the request is expensive or you want the server to stop doing work too.

const handleSearch = (query) => {
  const controller = new AbortController()
 
  searchApi.get(query, { signal: controller.signal })
    .then(response => setResults(response.data))
    .catch(error => {
      if (error.name !== 'CanceledError') {
        console.error(error)
      }
    })
 
  return () => controller.abort()
}

This is the version you'll see in useEffect cleanup functions constantly — React runs the cleanup before re-running the effect, which aborts the previous request before the new one starts.

One thing worth knowing: an aborted axios request still rejects its promise. It doesn't just disappear. So your .catch() will fire — but with a cancellation error, not a real error. If you don't filter that out, you'll log a scary-looking error every time the user types a new character, even though nothing actually went wrong.

why this matters more than it seems

The reason this category of bug is dangerous is that it's invisible in the conditions most people develop in. Fast machine, fast network, fast backend, single request at a time because you're testing slowly and deliberately. Every condition that would expose the race is the condition you don't have while coding.

It shows up in production, on someone's phone, on a flaky connection, where requests genuinely take different amounts of time to come back — and by the time it's reported, the bug report is "search shows wrong results sometimes," which is one of the worst bug reports to receive because "sometimes" hides exactly the information you need.

If you're debugging something like this, the first question worth asking isn't "what's wrong with this request" — it's "what if two of these were in flight at once, and the second one finished first." Most flaky async bugs live in that gap.

the takeaway

A single .then() chain is a straight line — request goes out, response comes back, you handle it. The moment you have multiple of those lines running at once, you don't have a line anymore, you have a race. And the version of your code that's correct for one request at a time can be silently wrong the instant two requests overlap.

Request ids and AbortController aren't exotic patterns — they're just the difference between code that assumes one thing happens at a time, and code that's honest about the fact that it doesn't.

← back to writing~/writing/async-race-conditions-stale-requests