Debugging 2026-07-06

Race Conditions in Frontend Code: Yes, You Have Them

A race condition happens when the correctness of your code depends on the order in which asynchronous operations complete — and that order isn’t guaranteed. JavaScript being single-threaded lulls people into thinking they’re immune. You’re not. The thread can’t race itself, but every fetch, every timer, every awaited promise is a gap where the world changes before your code resumes, and a frontend race condition is just two of those gaps resolving in the order you didn’t plan for. If your app talks to a network, you have these bugs. The only question is whether they’ve been reported yet.

Here’s the mechanism in its purest form, the search-typeahead classic:

function Search() {
  const [results, setResults] = useState([])

  async function onChange(e) {
    const res = await fetch(`/api/search?q=${e.target.value}`)
    setResults(await res.json())
  }
  // ...
}

The user types “re”, then “react”. Two requests go out. The ?q=re query hits a slow shard, or a cold cache, or just an unlucky route — and returns after ?q=react. Your code doesn’t care about send order; it applies whichever response lands last. The user is now looking at results for “re” under a search box that says “react”. They type nothing further, so nothing corrects it. To them, your search is simply wrong.

Notice what makes this nasty: every individual line is correct. The bug lives in the interleaving, which is invisible in the source and absent in your dev environment — localhost responds in single-digit milliseconds, in order, every time. “It never happened in dev” isn’t evidence of anything except that your laptop isn’t a cellular connection in a moving train. Chrome’s network throttling should be part of testing any async UI, and it almost never is.

Where frontend race conditions actually live

Beyond the typeahead, three shapes account for most of what I’ve debugged in the wild.

The double submit. User clicks “Place order”, nothing appears to happen for 800ms, user clicks again. Two POSTs, sometimes two orders. This is a race between the user and your feedback loop. Disabling the button on first click is necessary but not sufficient — a laggy render can let the second click through before React commits the disabled state. The real fix is idempotency: send a client-generated key so the server treats duplicates as one operation. Belt, suspenders.

const idempotencyKey = useRef(crypto.randomUUID())

async function submit(order) {
  await fetch('/api/orders', {
    method: 'POST',
    headers: { 'Idempotency-Key': idempotencyKey.current },
    body: JSON.stringify(order),
  })
}

The effect cleanup race. A component fetches on mount, the user navigates away before the response arrives, and the callback fires setState on an unmounted component — or worse, the component remounted for a different entity and receives the previous entity’s data. Profile page for user A rendering user B’s avatar: that genre.

Tab-switch refetch collisions. Lots of apps refetch on visibilitychange or window focus to freshen data. Now combine that with an in-flight mutation: user edits a field, tabs away to copy something, tabs back — the refetch races the save, and depending on which lands last, the edit either sticks or silently reverts. Users report this one as “the app ate my changes,” and they’re right.

The fixes that hold up

AbortController, wired into effect cleanup. The strongest fix for fetch-then-set races, because a cancelled request can’t overwrite anything:

useEffect(() => {
  const controller = new AbortController()

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') throw err
    })

  return () => controller.abort()
}, [userId])

When userId changes or the component unmounts, cleanup runs and the stale request dies mid-flight. The AbortError check matters — aborting rejects the promise, and you don’t want cancellations dressed up as failures in your error tracking.

Request versioning, for when you can’t or don’t want to abort (the response might still be worth caching, or the async thing isn’t a fetch):

const latest = useRef(0)

async function search(q) {
  const version = ++latest.current
  const results = await fetchResults(q)
  if (version === latest.current) setResults(results)
}

Last-write-wins, enforced explicitly. Every response checks whether it’s still the newest request before touching state; stale ones complete harmlessly and are ignored. This is essentially what the ignore-flag pattern in react.dev’s own effect documentation does with a boolean — versioning generalizes it to overlapping calls from any source, not just effect re-runs.

Or stop hand-rolling it. TanStack Query and SWR handle response ordering, deduplication, and focus-refetch coordination as core behavior. My honest position: if your app has more than a handful of server interactions, a data-fetching library eliminates this bug class more reliably than your team’s discipline will. The hand-rolled patterns above are for the seams where a library doesn’t reach — and for understanding what the library is protecting you from, because you’ll still hit the mutation-vs-refetch variety and need to reason about it.

Seeing the race after it happens

Debugging a reported race is its own misery, because the bug is the timing and the timing is gone by the time you hear about it. You can’t reproduce it on demand; you can only recreate the conditions and pray. This is one place where recording beats reasoning: a session replay makes races visible because you can see the actual event order — the keystroke, the two requests leaving, the responses landing transposed — laid out on a timeline instead of reconstructed from a user’s memory of “I typed and it showed the wrong thing.” The first time I watched a stale response land 400ms after the fresh one and overwrite it, on a recording, the fix took ten minutes. The three previous “cannot reproduce” rounds on the same ticket had taken two weeks.

Races also love company: the same unstable-value mistakes that cause response-ordering bugs cause infinite re-render loops, and I’ve more than once found both in the same component. If an effect’s dependencies churn, it refires fetches, and refired fetches race. Fix the stability problem and you often fix both.

The discipline that sticks, in one sentence: every await is a point where your assumptions may have expired, so every response must prove it’s still relevant before it touches state. Cancel it, version it, or let a library do it — but never apply a response just because it arrived.

See the bug the way your user did

LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.

Try LogReplay free