CodeStaminaOpen preview

Async state · TypeScript · 12 min

Stale async results: when the older request wins

Requests start in order. Responses do not. Any state update that ignores the difference is a race waiting for a slow network.

Train this

The problem

A search box fires a request on every keystroke. Each response replaces the results, sets or clears the error, and turns off the loading flag. The controller below is the version almost everybody writes first.

On a fast connection it behaves perfectly, which is exactly why this bug reaches production.

A search controller with no request identity
type Search = (query: string) => Promise<string[]>;
type State = { results: string[]; error: string | null; loading: boolean };

export function createSearchController(search: Search) {
  let state: State = { results: [], error: null, loading: false };
  return {
    getState: () => ({ ...state, results: [...state.results] }),
    async run(query: string) {
      state = { ...state, loading: true, error: null };
      try {
        const results = await search(query);
        state = { ...state, results };
      } catch (error) {
        state = { ...state, error: String(error) };
      } finally {
        state = { ...state, loading: false };
      }
    },
  };
}

PREDICT FIRST

The user types "a", then "ab". The request for "ab" resolves first, then the request for "a" resolves. What is on screen?

Commit to an answer before you read on. Being wrong here is the part that sticks.

Why it behaves that way

Every branch of the function writes to shared state after an await, and none of them ask whether they are still the current request. The success path, the error path and the finally block are all equally guilty — a stale rejection can plant an error message that belongs to a query the user has already moved past, and a stale finally can clear a loading flag that a newer request just set.

The instinct is to compare query strings: ignore the response if its query is not the current one. That fails as soon as the same string is searched twice — backspace and retype, or a retry — because both requests carry the same query and the older one still looks current.

What you need is request identity, not request content. A monotonically increasing token assigned at the start of each call gives every request a name. After each await, compare the token you captured against the latest one issued and bail out if you have been superseded.

This is logical cancellation: the stale work still runs to completion, it just loses the right to touch state. Physical cancellation with AbortSignal is a separate, complementary concern.

A request token that decides who may write
export function createSearchController(search: Search) {
  let state: State = { results: [], error: null, loading: false };
  let latest = 0;

  return {
    getState: () => ({ ...state, results: [...state.results] }),
    async run(query: string) {
      const token = ++latest;
      state = { ...state, loading: true, error: null };
      try {
        const results = await search(query);
        if (token !== latest) return;          // superseded: drop the result
        state = { ...state, results, error: null };
      } catch (error) {
        if (token !== latest) return;          // superseded: drop the failure
        state = { ...state, error: String(error) };
      } finally {
        if (token === latest) state = { ...state, loading: false };
      }
    },
  };
}

What the change buys you

BehaviourBeforeAfter
"a" then "ab", "ab" resolves firstShows results for "a"Shows results for "ab"
Stale request rejects after a newer one succeedsError banner over valid resultsRejection ignored
Stale request settles while a newer one loadsLoading flag cleared earlyLoading stays true until the current request settles
The same query searched twiceIndistinguishable from a stale responseDistinct tokens, newest wins

Variations worth trying

  1. Three or more overlapping requests, settling in an order you choose. Any implementation that only tracks "the previous request" breaks here.
  2. Swap the token for an AbortController per request and abort the previous one. You still need the token check: aborting is a request, not a guarantee.
  3. Move the same controller into a React effect and add a cleanup function. The cleanup gives you a natural place to invalidate, and a natural place to get the dependency array wrong.
  4. Apply it to a paginated list where a fast page 2 arrives before a slow page 1, and note that "newest wins" is now the wrong rule — you want per-page identity instead.

TRAIN IT

The result that arrived too late

The full 15-minute workout: six requirements, overlapping requests, and a test suite that will not let a stale response through.

Open the workout

JOIN THE WAITLIST

Help shape what we build next.

Tell us where you want to start and what you want to practise. We’ll use your answers to understand demand and email you about relevant program availability.

What would you like to practise?

We store your email and answers for this waitlist. No campaign is being sent now. Contact mo@codestamina.com to ask us to remove your entry.