Back to the crumb
The lesson
nextChewy~30 min

Why the search settles on the wrong word

What you saw

You type sourdough at a normal speed. The box says sourdough. The list underneath fills with a broad jumble — Sea salt, Spelt flour, Strong white bread flour, half the catalog — and the counter insists these are the matches "for sourdough." Type the same word slowly, with a real pause between each letter, and it's flawless. The faster you type, the more wrong it gets.

The speed dependence is the entire clue. Nothing is wrong with any single search. Something is wrong with what happens when several of them are in the air at once.

What's actually happening

Every keystroke fires its own request. Look at app/page.js:

function onChange(e) {
  const next = e.target.value
  setQuery(next)
  runSearch(next)        // one fetch per keystroke
}

async function runSearch(q) {
  setSearching(true)
  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
  const data = await res.json()
  setResults(data.results)   // whoever finishes last wins
  setSearching(false)
}

Typing sourdough quickly launches nine overlapping requests: one for s, one for so, one for sou, all the way to sourdough. That alone would be fine if they all came back in the order you sent them. They don't.

Here's the part that makes it bite. The search endpoint doesn't take a constant amount of time. From app/api/search/route.js:

function rankingLatency(query) {
  const base = 2200 - query.length * 220   // shorter query → slower
  // ...
  return Math.max(180, base + jitter)
}

A short, broad query like s matches and scores most of the catalog, so it takes the longest — around 2 seconds. The long, specific sourdough matches almost nothing and comes back fast — around 0.2s. This is not an artificial quirk; real autocomplete backends behave exactly this way. Broad queries are expensive, specific queries are cheap, cache hits beat cache misses. Latency is not uniform, and it is not ordered.

So when you type sourdough at a normal pace (the whole word lands in roughly a second, well before the first request returns), the requests resolve in roughly reverse order:

sent:      s → so → sou → sour → ... → sourdough
fires:     across about a second of typing
returns:   sourdough (~0.2s) first ... s (~2s) LAST

sourdough's results arrive first and render. Good — for a moment. Then sour's land and overwrite. Then sou. Then so. Finally s — the slowest, broadest, oldest request — arrives dead last and stamps its results over everything. You're left staring at the matches for s while the box says sourdough.

This is a race condition: the correctness of the result depends on the order asynchronous operations happen to finish, and that order isn't guaranteed. "Last await to resolve calls setResults" quietly assumes responses come back in send order. Over a network, they never promise to.

Type slowly and each request finishes before the next one starts, so send order and finish order match and the bug hides. That's why it only shows up under fast input — and why it's a nightmare to catch in a calm manual test and a regular sighting in production.

The fix

The bug isn't that requests race — you can't stop them from racing. The fix is to ignore the results of any request that is no longer the one the user is waiting for. Three good ways, in rising order of how much they do for you.

1. Guard with the latest query. The response already echoes its query ({ query, results }). Before rendering, check it's still current:

const [query, setQuery] = useState('')

async function runSearch(q) {
  if (!q.trim()) { setResults([]); return }
  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
  const data = await res.json()
  // Drop anything that isn't the answer to what's in the box right now.
  setQuery((current) => {
    if (data.query === current) setResults(data.results)
    return current
  })
}

(Reading the latest value via the setQuery updater avoids a stale closure over query — see how that's its own classic trap.) A request id counter works the same way and is sturdier when two identical queries can be in flight:

const latest = useRef(0)

async function runSearch(q) {
  const id = ++latest.current
  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
  const data = await res.json()
  if (id === latest.current) setResults(data.results)   // stale ids ignored
}

2. Cancel the loser with AbortController. Don't just ignore the stale response — abort the request so it never finishes (and you don't waste a round trip):

const controller = useRef(null)

async function runSearch(q) {
  controller.current?.abort()              // kill the previous in-flight request
  const ac = new AbortController()
  controller.current = ac
  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: ac.signal })
    setResults((await res.json()).results)
  } catch (err) {
    if (err.name !== 'AbortError') throw err
  }
}

3. The same idea inside useEffect. If you drive the search off query with an effect, the cleanup function is the natural place to invalidate the previous run:

useEffect(() => {
  if (!query.trim()) { setResults([]); return }
  let active = true
  fetch(`/api/search?q=${encodeURIComponent(query)}`)
    .then((r) => r.json())
    .then((data) => { if (active) setResults(data.results) })
  return () => { active = false }   // a newer keystroke marks this run stale
}, [query])

What about debounce?

Debouncing — waiting until the user pauses before searching — is a great optimization and you should probably add one. But it is not the fix, and reaching for it first is the trap. Debounce makes overlapping requests less likely; it doesn't make the late one safe to render. Two requests can still overlap (a slow network, a query right at the debounce edge), and when they do, the race is back. Debounce reduces how often you roll the dice. The guard/abort makes the dice not matter. Ship the guard; add debounce on top for fewer requests.

The bug class: async race condition (out-of-order responses / unhandled stale results)

This is the race condition from out-of-order async responses — sometimes called the "stale closure of the network." Any time you fire async work in response to fast-changing input and write the result straight into state, you're assuming the responses arrive in the order you sent them. They don't. The newest request can lose to an older, slower one.

How to spot it in the wild:

The rule that prevents it: a response is only allowed to update state if it's still the response you're waiting for. Tag each request (an id, the query string, an AbortController) and, when it resolves, check it's still the latest before you touch state — or cancel the ones you've superseded. Treat "the last await to finish" as untrustworthy by default, because over a network, finish order is never send order.