skip to content

Race Conditions and Out-of-Order Responses

Two requests fired from the same effect can resolve in the wrong order and paint stale data over fresh data. The ignore-flag set in the cleanup function is the canonical fix, and being handed a naive fetch-in-effect snippet and asked to find the bug is extremely common.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

4

A React component fetches inside an effect: useEffect(() => { fetch('/api/users/' + userId).then(r => r.json()).then(setUser); }, [userId]). When the user switches userId from 1 to 2 quickly, the page sometimes ends up showing user 1's data. Why does that happen, and how do you fix it inside the effect?

level: middleimportance: must knowfreq 72%

answer

  1. two requests, one state slot
  2. arrival order, not start order
  3. cleanup runs before the next run
  4. let ignore = false inside the effect body
  5. guard the state write, not the request

basics

~20 s

Two fetches for different ids can resolve in the wrong order, so the older response's state update overwrites the newer data. Fix it with an ignore flag declared inside the effect, set to true in its cleanup, that guards every state write.

solid answer

~50 s

Changing `userId` re-runs the effect, so two requests are in flight at once. Nothing makes HTTP responses arrive in the order they were sent — the request for user 1 can resolve after the request for user 2 — and because both `.then` callbacks call the same `setUser`, whichever arrives last wins. That is the classic out-of-order response race, and it shows the wrong user until something else triggers a fetch. The fix is a flag scoped to a single effect run: declare `let ignore = false` at the top of the effect body, check `if (!ignore)` before every state write, and return a cleanup that sets `ignore = true`. React runs that cleanup before re-running the effect for the new `userId` and on unmount, so the previous run's closure sees `ignore === true` and drops its result. The request still completes; only its state write is discarded.

code

javascript · 23 lines
javascript
import { useEffect, useState } from 'react';

export function useUser(userId) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;
    fetch('/api/users/' + userId)
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) setUser(data);
      })
      .catch((err) => {
        if (!ignore) setError(err);
      });
    return () => {
      ignore = true;
    };
  }, [userId]);

  return { user, error };
}

go deeper

for a junior

Know that changing a dependency runs the effect again, so two requests can be open at once, and that the last state update to run is the one you see. Be able to point at the missing cleanup in the snippet.

for a middle

Explain the mechanics out loud: one state slot, two closures, arrival order deciding the winner, and the cleanup running before the next effect run so the previous run's local flag flips. Show where the check goes for data, error, and loading writes.

for a senior

Show you can reproduce it deliberately with a throttled or delayed stub instead of calling it flaky, and say plainly that the guard fixes correctness while doing nothing about wasted bandwidth or server load.

for a principal

Frame it as a class of bug rather than one snippet: unguarded async writes are a defect any fetch-in-effect can have, so argue for a single reviewed place where responses are matched to the request that asked for them instead of relying on every author remembering the flag.

## What the code is doing `useEffect(fn, [userId])` runs `fn` after the commit that follows a render where `userId` changed. So when `userId` goes 1 → 2, React runs the effect a second time and a second `fetch` starts. Both calls capture their own `userId` in a closure, but both funnel into the same `setUser` setter, and component state has exactly one slot for the user. ## Why the order is not guaranteed The two requests are independent. They may travel on different connections, hit different backend instances, be served partly from cache, or be slowed by a garbage-collection pause on the server. A request that starts first can easily finish second. No part of the stack promises otherwise: not `fetch`, not the browser, and certainly not React, which has no idea your effect started asynchronous work at all. React does not cancel, queue, or serialize anything when the dependencies change — it just calls your cleanup and runs the effect again. ## Last write wins, and the last write may be the oldest request Because both callbacks call `setUser`, the UI ends up displaying whichever response landed last. If the user-1 response is slow, the sequence is: request 1 starts, request 2 starts, request 2 resolves and renders user 2, request 1 resolves and renders user 1. The screen now shows data that no longer matches the current `userId` and it stays wrong until some other update re-fetches. This is intermittent by nature, which is exactly why it survives code review and reaches production: on a fast local network the race almost never loses. ## The fix: a flag scoped to one effect run ```js useEffect(() => { let ignore = false; fetch('/api/users/' + userId) .then((res) => res.json()) .then((data) => { if (!ignore) setUser(data); }); return () => { ignore = true; }; }, [userId]); ``` The important word is *inside*. `ignore` is a plain local variable created fresh on every run of the effect, and the `.then` callback of that run closes over that run's binding. When `userId` changes, React calls the previous run's cleanup first, flipping that run's `ignore` to `true`; the new run then starts with its own `ignore = false`. Late responses from any earlier run therefore see a `true` flag and drop themselves, while the newest run is never affected. The same cleanup covers unmount: React runs it when the component goes away, so a response that lands after the component is gone also finds `ignore === true`. ## Guard every write, not just the happy path A stale error is as bad as stale data. If the effect has a `.catch` that calls `setError`, or an `async` body with `try/catch`, put the `if (!ignore)` check in front of those writes too. The same applies to a `setLoading(false)` at the end — an older request finishing last should not clear the loading state of the newer one. With `async/await` the shape is the same: ```js useEffect(() => { let ignore = false; (async () => { try { const res = await fetch('/api/users/' + userId); const data = await res.json(); if (!ignore) setUser(data); } catch (err) { if (!ignore) setError(err); } })(); return () => { ignore = true; }; }, [userId]); ``` Note that the effect callback itself must not be `async` — React expects the return value to be a cleanup function or nothing, and an `async` function returns a promise. ## What the flag does not do It is a correctness guard, not a resource guard. The obsolete request still travels, the server still does the work, the body is still downloaded and parsed, and the bytes still count against a mobile data plan. Discarding the result is enough to keep the UI correct; reducing the number of in-flight requests is a separate decision with its own tradeoffs. ## Reproducing it Because it is timing-dependent, force it: throttle the network in DevTools, or point the effect at a stub that delays by id (`await sleep(id === 1 ? 2000 : 50)`), then flip the id quickly. If the wrong record renders, you have reproduced the race; add the flag and it disappears while the underlying requests behave exactly as before.

  • Does setting the ignore flag stop the request that is already in flight?
    No. The flag only guards the state write. The request still travels, the server still does the work, and the response body is still downloaded and parsed — you simply throw the result away. Cutting the request short is a separate lever with its own cost, and it is not required for correctness.
  • Why declare the flag inside the effect body instead of holding it in a ref?
    Because each effect run needs its own binding. A ref is one shared value across all runs, so the newest run would overwrite whatever the older run set and the old response would sail through the check. A local variable is created fresh per run, and each run's callbacks close over exactly their own copy.
  • Does the error path need the same guard?
    Yes. If an older request rejects after the newer one has already rendered good data, an unguarded catch writes a stale error and blanks the screen. Put the check in front of every write the request performs — data, error, and any loading flag — so a discarded response touches nothing.

It is like posting two letters a minute apart. Nothing guarantees the first one arrives first, so you number them and throw away anything that turns up older than what you already have.

saying these in an interview costs you the question

  • Says a loading boolean prevents the stale overwrite
  • Claims responses always arrive in request order
  • Puts the flag in a ref shared across effect runs
  • Thinks React cancels pending work when deps change
  • Guards only the success path, not the error path

context

open as a page

A React developer tries to stop out-of-order fetch responses by keeping const isMounted = useRef(true), only calling setState when isMounted.current is true, and tracking a loading boolean in state. Why does neither guard prevent an older response from overwriting newer data when the effect re-fetches on a prop change?

level: middleimportance: should knowfreq 46%

basics

~20 s

Both guards are component-scoped, not request-scoped. The component is still mounted when the late response lands, and one shared loading flag cannot say which request a response belongs to, so the stale write passes. The guard must live inside a single effect run.

open as a page

In a React component, a refresh button and a 30-second poll both call the same async loader while the component stays mounted, so no effect cleanup runs between the two in-flight requests. How do you guarantee that only the newest response is written to state?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Stamp each request with an incrementing id kept in a ref, capture that id in the call, and write state only when it still equals the latest id. Anything older is discarded, so responses are matched to the request that asked for them.

open as a page

In a React app, a search box fires a request on every keystroke. You could let every request fly and discard responses that no longer match the current query, abort the previous request before starting the next, or debounce the input so fewer requests start at all. How do you decide between them, and what does each one actually cost?

level: principalimportance: should knowfreq 34%

basics

~20 s

Discarding stale responses is the only technique that guarantees correctness, so keep it unconditionally. Debouncing cuts request volume at the cost of perceived latency, and aborting frees client and server resources, but neither one replaces the staleness check.

open as a page