Why does my debounced async search sometimes show older results?

Hey everyone, I’m wiring up a typeahead search in a UI and I’m seeing a weird failure mode: if I type fast, the list occasionally “snaps back” to an older query’s results. I’m trying to keep it responsive without leaking requests or doing a ton of extra renders.

let lastQuery = "";
let timer;

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

export function onInput(e, setState) {
  const q = e.target.value;
  lastQuery = q;
  clearTimeout(timer);
  timer = setTimeout(async () => {
    const data = await fetchResults(q);
    setState({ q: lastQuery, results: data });
  }, 200);
}

What’s the cleanest way to guarantee only the latest query updates state (without making the UI feel laggy or piling up aborted fetches)?

BayMax

Older responses win here because fetches can finish out of order. The debounce only delays when you start work; it does not stop an earlier request from resolving later and overwriting newer state.

Keep the debounce, then add a request counter so only the latest response is allowed to commit:

let timer;
let seq = 0;

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

export function onInput(e, setState) {
  const q = e.target.value;
  const mySeq = ++seq;

  clearTimeout(timer);
  timer = setTimeout(async () => {
    const data = await fetchResults(q);

    if (mySeq !== seq) return; // stale response, ignore it

    setState({ q, results: data });
  }, 200);
}

If you want to cancel in-flight requests too, use an AbortController, but the sequence check is the part that guarantees latest-wins behavior.

Hari

Also make sure you’re not closing over a stale q inside the timeout; grab the latest value when the timer fires or pass it through like you did, otherwise you can “win” with the right seq but still commit the wrong query’s results. If you add AbortController, abort the previous controller right before starting the new fetch to reduce wasted work and UI flicker.

Quelly

@Quelly, that “right seq but wrong query” bug usually comes from the timeout callback reading an old q even though your counter is correct.

A simple guard is to only commit results if the response’s query still matches the current input value, so a fast cached response can’t snap you back.

BayMax

@Baymax, Even with the query-match guard, watch for IME/composition input (Japanese/Chinese) firing interim values—skip searching while e. isComposing (or wait for compositionend) so you don’t commit results for half-formed text.

BobaMilk

Also worth checking request ordering: even with debounce, older fetches can resolve after newer ones, so cancel with AbortController or ignore responses that don’t match the latest request id.

WaffleFries

Debounce only delays the cast, it doesn’t stop an older fetch from landing after the newer one and overwriting your list.

AbortController or a simple incrementing requestId check keeps only the latest response (like “search #42”) allowed to update state.

VaultBoy