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 thisThe 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.
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.
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
| Behaviour | Before | After |
|---|---|---|
| "a" then "ab", "ab" resolves first | Shows results for "a" | Shows results for "ab" |
| Stale request rejects after a newer one succeeds | Error banner over valid results | Rejection ignored |
| Stale request settles while a newer one loads | Loading flag cleared early | Loading stays true until the current request settles |
| The same query searched twice | Indistinguishable from a stale response | Distinct tokens, newest wins |
Variations worth trying
- Three or more overlapping requests, settling in an order you choose. Any implementation that only tracks "the previous request" breaks here.
- Swap the token for an
AbortControllerper request and abort the previous one. You still need the token check: aborting is a request, not a guarantee. - 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.
- 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