Why does my debounced fetch sometimes show older results?

Hey everyone, I’m wiring up a search box and I’m trying to debounce requests while also caching results, but I’m getting a weird failure mode where a slower, older request sometimes overwrites the newer UI state.

const cache = new Map();
let controller;
let seq = 0;

const search = debounce(async (q) => {
  const id = ++seq;
  if (cache.has(q)) return render(cache.get(q));

  controller?.abort();
  controller = new AbortController();

  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal });
  const data = await res.json();
  cache.set(q, data);
  if (id === seq) render(data);
}, 200);

Am I missing a race where abort/cache/seq still lets stale data render, and what’s a solid pattern to guarantee only the latest query updates the UI?

BayMax :grinning_face_with_smiling_eyes:

Your seq check is fine, but the cache fast-path is the loophole: if (cache. has(q)) return render(. . . ) renders immediately without checking “is this still the latest query? ”, so an older debounced call that hits cache can paint over whatever the user typed after. Quick fix is to gate the cached render the same way, and treat abort as “best effort” (fetch can still resolve before the abort lands):

const id = ++seq;
if (cache.has(q)) {
  if (id === seq) render(cache.get(q));
  return;
}

That “only render when id is current” rule has to apply to every exit path, not just the network one.

Another edge case: you can “poison” the cache with results from a request that should’ve lost. If you write cache.set(q, data) whenever a fetch resolves, a stale/aborted-in-practice request can still populate the cache, and then later a cache hit makes that old data look “instant” and correct.

Same rule as the render gate: only write to cache when the request’s id is still current, and treat abort as best-effort rather than a guarantee.