Under the Hood
Frontend

Race conditions in async UI code

Type "re" then quickly "react" into a search box and two requests go out — nothing guarantees the "re" response arrives second. This lesson works through the exact failure (a faster request finishing first and overwriting newer, correct state with stale data), the request-id-tagging fix in full, and why real cancellation with AbortController is strictly better than discarding a result after the fact.

Race conditions in async UI code

Type "re" into a search box, then keep typing "act" a beat later. Two fetch calls go out: one for "re", one for "react". Both are perfectly ordinary requests, and both will eventually resolve with exactly the data they asked for. And yet a very common way of writing the response handler produces a UI bug that has no error message, no failed request, and no stack trace — it just silently shows the wrong thing on screen.

The governing insight for this whole lesson: the order responses arrive in is not guaranteed to match the order requests were sent in, and any code that updates state directly from a response, without checking whether that response is still relevant, is trusting a guarantee that does not exist.

Here's the map. First, the concrete failure with a search box. Then why it's a race and not a bug in either request. Then the standard fix — tagging requests with an id and discarding stale responses — worked through in full, with code. Then a better fix that this lesson only sketches and the next lesson finishes: actually cancelling the stale request instead of just ignoring its answer. And finally the general shape of the problem, so you recognize it outside of search boxes.

The failure, concretely

Say the search box is wired up the naive way: every keystroke fires a request, and the response handler sets state with whatever comes back.

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

  const handleChange = async (e) => {
    const query = e.target.value;
    const res = await fetch(`/api/search?q=${query}`);
    const data = await res.json();
    setResults(data); // naive: trusts arrival order
  };

  return <input onChange={handleChange} />;
}

The user types "re". A request for q=re goes out. A fraction of a second later they type "act", and the input is now "react" — a second request, q=react, goes out. Two requests are now in flight, and the component is currently waiting on both.

Under normal conditions the "react" response comes back after the "re" response, in the order you'd expect, and everything looks fine. But nothing in fetch, in HTTP, or in the network between the browser and the server promises that order. The "re" query might hit a lightly-loaded shard while "react" hits one doing a slow join. A load balancer might route them to different backend instances with different response times. A single dropped packet on the "re" connection triggers a TCP retransmission and adds a round trip that "react" never has to pay. Any of these — and plenty of other ordinary, unremarkable network conditions — can make the "re" response arrive after the "react" response.

When that happens, here's the sequence on screen: the "react" response arrives first, setResults runs, and the user sees exactly what they typed — search results for "react". A moment later, the "re" response arrives, setResults runs again, and now the screen shows results for "re" — two characters, stale, wrong — while the input still reads "react". No exception was thrown. No request failed. The UI is simply showing the answer to a question the user is no longer asking, and it will sit there wrong until another keystroke happens to trigger a new, correct request.

Why this is a race, not a bug in either request

It's worth being precise about where the bug actually lives, because it isn't where it looks. The q=re request did its job correctly — you asked the server for results matching "re", and it gave you exactly that. The q=react request also did its job correctly. Neither request is malformed, neither response is wrong, and if you tested each endpoint in isolation you'd find nothing broken.

The bug lives entirely in the assumption sitting between "a response arrived" and "I should update state with it" — the assumption that responses complete in the same order their requests were sent. That assumption isn't backed by anything in the fetch API, the HTTP spec, or TCP. It's just usually true, on a fast, uncongested network, which is exactly why this class of bug survives so long in development and shows up in production the first time a real user is on a slower connection, or the backend is under real load. This is what makes it a race: two independent operations are in flight concurrently, the code's correctness depends on an unstated assumption about their relative finishing order, and that order isn't controlled by the code at all — it's decided by the network and the server, both outside your program.

Fix 1: tag requests, discard stale responses

The fix doesn't try to make responses arrive in order — you can't control that. Instead, it makes the response handler stop blindly trusting whichever response shows up. The technique: give every outgoing request an identity at the moment you send it, remember which identity is the latest one you actually care about, and when a response comes back, check it against that latest id before touching state. If it isn't the latest, throw the response away — the request still succeeded, you just don't act on it.

function SearchBox() {
  const [results, setResults] = useState([]);
  const latestRequestId = useRef(0);

  const handleChange = async (e) => {
    const query = e.target.value;

    // Tag this request at the moment it's fired, and record that
    // it's now the one we care about.
    const requestId = ++latestRequestId.current;

    const res = await fetch(`/api/search?q=${query}`);
    const data = await res.json();

    // By the time this response arrives, has a newer request
    // been fired? If so, this one is stale — discard it.
    if (requestId !== latestRequestId.current) {
      return;
    }

    setResults(data);
  };

  return <input onChange={handleChange} />;
}

Walk through the search-box scenario again with this in place. Typing "re" fires a request tagged requestId = 1, and latestRequestId.current becomes 1. Typing "react" fires a second request tagged requestId = 2, and latestRequestId.current becomes 2. The "react" response arrives first: its requestId (2) matches latestRequestId.current (2), so setResults runs and the screen shows the right thing. The "re" response arrives after: its requestId (1) no longer matches latestRequestId.current (2), so the if check catches it and the function returns before setResults is ever called. The stale data never touches state. From the user's point of view, the "re" response might as well not have arrived at all.

Note what this fix does not do: it doesn't stop the "re" request from completing. The request for "re" still went all the way to the server, the server still did the work of computing an answer, the response still traveled all the way back over the network — all of that happened, and all of it was thrown away the instant it landed. The fix only prevents the result of that work from reaching the UI. That's a real fix for the visible bug, and it's the right first fix to reach for, but it leaves waste on the table.

Fix 2: cancel the stale request instead of just ignoring it

Discarding a stale response fixes the correctness bug, but the stale request itself was never stopped — it ran to completion, consumed bandwidth on the way there and back, occupied a connection slot the browser could have used for something else, and made the server do real work for an answer nobody was going to look at. On a single search box that waste is trivial. Multiply it by every user, every keystroke that gets superseded before its response arrives, and it stops being trivial.

The strictly better fix is to stop the outdated request itself, rather than let it finish and throw away what it returns. The browser gives you a mechanism for exactly this: AbortController. Instead of tagging a request and checking the tag later, you keep a handle to the in-flight request, and the moment a newer one starts, you call .abort() on the previous handle — which causes the browser to stop the network request outright and makes the earlier fetch promise reject, rather than resolve with data you'd have to remember to ignore. The waste Fix 1 tolerates — a completed request whose answer gets thrown away — simply doesn't happen, because the request never gets the chance to complete.

That's the idea at the level you need to recognize why it's better. The full mechanism — how AbortController and AbortSignal actually work, how to wire a signal into fetch, and how to handle the AbortError that a cancelled fetch throws — is the entire subject of the next lesson, AbortController: cancelling what you no longer need. This lesson stops at the sketch on purpose: Fix 1 is what you reach for first because it works with plain state and no extra API, and Fix 2 is what you reach for once you understand exactly what it's saving you from having to tolerate.

The general shape of the problem

Nothing about this is specific to search boxes or even to fetch. The pattern is: state gets updated from the result of some asynchronous operation, more than one instance of that operation can be in flight at the same time, and the code that writes to state assumes — usually silently, usually without anyone deciding to make that assumption — that operations finish in the order they started. The moment that assumption is false even once, state gets overwritten with a result that's technically valid but no longer relevant, and there's nothing in the symptoms to point you at the cause: no error, no warning, just a screen that's quietly showing the answer to a question that isn't being asked anymore.

Anywhere you see "fire an async operation on an event that can happen again before the first one finishes" — keystrokes, tab switches, button clicks that re-trigger a fetch, a useEffect that depends on a prop that can change again mid-request — that pattern is present, and the fix is the same shape every time: either check relevance before acting on a result, or stop the outdated operation before it produces one. Modeling connection state as a machine, not booleans is the same underlying discipline applied one level up — not trusting an implicit assumption about timing or ordering, and instead making the thing you actually care about (which request is current, which state a connection is in) an explicit, checkable value rather than something inferred from whatever happened to run last.

Go deeper

  • MDN — AbortController The mechanism Fix 2 hands off to — cancelling the outdated request outright rather than discarding its result after the fact, covered in full in the next lesson.
  • React docs — Synchronizing with Effects Covers the exact 'ignore this result if a newer effect run has already started' pattern for data fetching inside useEffect, which is this lesson's Fix 1 in React's own idiom.
  • Ably — realtime delivery guarantees and reconnection A clear treatment of why realtime transports and network responses in general are not guaranteed to arrive in send order, and why reconciling against a stable source of truth (rather than trusting arrival order) is the general-purpose fix.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. A user types into a search box and the results briefly flash correct, then wrong, then correct again a moment later. Walk through what's happening across the in-flight requests.
  2. Why is it wrong to describe this bug as 'the /api/search endpoint is broken' or 'the second fetch failed'? What did each individual request actually do?
  3. In the request-id fix, why is the id comparison done after `await res.json()` and not right after the fetch is fired?
  4. What specifically would break if `latestRequestId` were reset to 0 inside the response handler instead of only ever being incremented when a new request starts?
  5. Fix 1 discards a stale response. What cost does that still leave on the table that Fix 2 removes, and why doesn't Fix 1 remove it too?
  6. If `AbortController` is strictly better than discard-by-id, why would a codebase ever still use the discard-by-id pattern?
  7. Describe another UI scenario, not search-as-you-type, where this exact race condition could occur, and identify the two things whose relative order isn't actually guaranteed.