ReactNov 7, 2026 · 6 min read

my search box showed the wrong results

I was testing a search box I had just built, a simple one, type a few letters and a dropdown fills in. I typed 'react', changed my mind, backspaced the whole thing, typed 'vue', and for half a second the dropdown showed React results under the word 'vue'. Then it corrected itself, like nothing had happened. I stared at it, because I knew the code. One fetch per keystroke, results straight into state. Nothing in there looked wrong.

I tried to reproduce it and could not, not reliably. Sometimes the results were right, sometimes they were stale, and the difference was the network. That took me a while to accept. The bug was not in my code in the usual sense. It was in the assumption the code was built on, that responses come back in the order you sent them. They do not.

Live: type fast, or hit replay, and watch the stale results
Showing results for: —

Try it above. Type fast, or hit Replay the race, which fires 'react' and then 'vue' a moment later, with the first request deliberately slower than the second. With the guard off, the react results land after the vue ones and paint over them, while the input still says 'vue'. Flip the guard on and the stale answer gets dropped. That is the whole bug, in a box you can poke at.

The responses come back in whatever order they like

Here is the shape of it. Every keystroke fires a fetch, and each fetch is its own trip to the server. The network does not queue them up politely. A short query can come back faster than a long one, a cached answer can beat a fresh one, and a slow connection can hold the first request just long enough for the second one to overtake it. Nothing is broken. This is what concurrent network calls do.

The code that paints the results does not know any of this. It takes whatever response arrives and puts it on screen. If the 'react' response lands after the 'vue' one, the box shows react results while the input says 'vue'. The bug is not in the fetch call. It is in treating 'the last response that arrived' as if it were 'the response to my last request'. Those are the same thing only when nothing is racing, and in a search box something is always racing. I read a post about this that names the shape precisely, and it stuck with me.

Debounce makes it rarer, not gone

The first fix I reached for was debounce. Wait 250ms of silence before firing, fewer requests, fewer chances to collide. It worked, in the sense that the bug got rarer. It did not go away. Two debounced requests can still be in flight at the same time, and they can still finish in the wrong order. A short query can still beat a long one home. Debounce is a good idea for other reasons, fewer requests is genuinely better, but it is not a fix for this. It just makes the race harder to catch, which is arguably worse, because now it only shows up on slow connections, which is exactly when your users are already having a bad time.

SearchBox.jsxTypeScript
const timerRef = useRef(null);

function onChange(value) {
  setQuery(value);
  clearTimeout(timerRef.current);
  timerRef.current = setTimeout(() => runSearch(value), 250);
}

async function runSearch(q) {
  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
  const data = await res.json();
  setResults(data);
}

Check the id before you paint

The fix that stuck is small. Give every request a number, and when a response arrives, check that it is still the latest request before you paint it. If a newer search started while this one was in flight, throw the answer away. The requests still race each other exactly as before. You have just stopped letting the loser win.

SearchBox.jsxTypeScript
const latestRequestId = useRef(0);

async function runSearch(query) {
  const requestId = ++latestRequestId.current;
  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  const data = await res.json();
  if (requestId !== latestRequestId.current) {
    return; // a newer search started while this one was in flight
  }
  setResults(data);
}

The guard lives in a useRef, not in state, because it changes on every keystroke and you do not want a re-render for it. The check happens after the await, which is the whole point. That is the gap where the race lives, between starting the request and acting on its result.

Or cancel the stale request

There is a second version of the fix, and it is the one I reach for when the request is heavy. Instead of ignoring the stale answer, cancel the request outright. AbortController does that. You make a new controller for each search, pass its signal to fetch, and abort the previous one before firing the next. In React the cleanup function of useEffect is the natural place for the abort.

SearchBox.jsxTypeScript
useEffect(() => {
  if (!query) return;
  const controller = new AbortController();
  fetch(`/api/search?q=${encodeURIComponent(query)}`, {
    signal: controller.signal,
  })
    .then((res) => res.json())
    .then(setResults)
    .catch((err) => {
      if (err.name !== 'AbortError') setError(err);
    });
  return () => controller.abort();
}, [query]);

Two details worth knowing. One, aborting makes the fetch reject with a DOMException named AbortError, so the catch has to ignore that name specifically, or you will treat your own cancellation as an error and flash an error state at the user. Two, a signal can only be used once, so each search needs a fresh controller. The cleanup runs before the next effect and on unmount, which is exactly when you want the old request to die.

Which one to use? The counter is the smallest fix, and it works when you do not control the request layer. The AbortController is the better one when the request costs something, because it stops the download instead of just ignoring the answer. I use the counter for light searches and the controller for anything heavier. Both of them guard the same thing, the identity of the request, not the order of arrival.

It is not just search boxes

Once you see the shape, it shows up everywhere two async calls can overlap and only one should win. Tabs in a dashboard, click 'Revenue' then 'Users', and if Revenue's data is bigger and arrives after Users', the Users tab briefly shows revenue numbers. Infinite scroll with a filter change mid-scroll, the old filter's page lands after the new one. Autosave, where a slow first save overwrites a faster second one. The fix is the same every time. Attach an identity to the thing you are waiting for, and check it is still current before you act on the answer.

The search box I started with had one fetch per keystroke and nothing checking which response it was looking at. That is the default shape of the code, and it works fine until the network disagrees with you, which it eventually does. The guard is a few lines. I put it in now, before the bug report, because the bug only shows up when someone is typing fast on a slow connection, and that person is not going to file a report. They are just going to think the search is broken.