Stop stale fetch requests from replacing newer results
Reproduce a stale search result, cancel obsolete work, and guard the UI commit. Includes a cancellation-blind fixture and cleanup regression tests.
The search box says ab, but the list has quietly gone back to results for a. Nothing failed at the HTTP layer. Two valid responses arrived out of order, and the older one was allowed to replace the newer intent.
For a frontend developer who already uses promises and fetch, the useful fix is a small ownership rule: only the current request may commit to this view. We will reproduce the wrong result first, add cancellation plus a final guard, and test obsolete successes, failures, and teardown. This is a read-result pattern, not a way to undo a server mutation.
Start with the mismatch the reader can actually see
Both panels below receive the same two responses in the same order. The input remains ab. The response for ab resolves first; the response for a resolves second. The unguarded panel ends with Animation field notes from the a response. The guarded panel keeps Abstract layout guide from ab. Those resource names are fictional fixture data.
The fixture resolves promises explicitly instead of waiting for a timer. Its transport ignores cancellation on purpose. That makes the result check observable: a test that stopped every old response at the transport layer could pass even if the view still accepted stale work. This visualizes the loader output, not a real network performance comparison.
Reproduce the race without a slow network
The broken function below does not know which invocation is current. Every completed response calls render. If loadResults is called twice, whichever invocation reaches that line last wins, even when it started first.
Keep the test deterministic: start A, start B, resolve B, then resolve A. The failure is about ordering, not a particular number of milliseconds. Assert the final visible value and the complete render history so a brief stale flash is not hidden by a later correction.
Debouncing can avoid starting some requests. It cannot decide which already-running request owns the screen. Add input debouncing only after the ownership test passes, then keep that test in place.
async function loadResults(query) {
const response = await fetch(`/search?q=${encodeURIComponent(query)}`);
const result = await response.json();
render(result); // Whichever response finishes last wins.
} | Event | Unguarded view | Owned result |
|---|---|---|
| A starts | Waiting for A | A owns the view |
| B starts | Waiting for A and B | B owns the view; A is cancelled |
| B completes | B is visible | B is visible |
| A completes late | A incorrectly replaces B | A cannot commit |
Give each request its own cancellation signal
Create a controller when one load begins. Pass its signal to fetch, and retain that same controller across all awaits in the invocation. The next load aborts it before becoming current. MDN documents the single-use nature of an aborted signal, so create a new controller instead of trying to reset the old one.
Choose the owner by what can be replaced independently. Two search panels need two loader instances. A module-level singleton would let typing in one panel cancel the other panel, which is a different bug hidden behind the same helper.
Protect the commit, not just the request
Cancellation asks obsolete work to stop; the signal check decides whether its result still belongs on screen. Keep that check after response parsing and after any later asynchronous transformation. Checking only before an await leaves a gap where another load can supersede this one.
The finally block clears current only when the invocation still owns it. Without that identity check, a late A can erase B's controller, and the next load can no longer cancel B. render and reportError are synchronous view callbacks in this example; the helper does not manage a spinner or a framework lifecycle for you.
export function createLatestLoader({ fetchImpl = globalThis.fetch, render, reportError }) {
let current;
return {
async load(url) {
current?.abort();
const controller = new AbortController();
current = controller;
try {
const response = await fetchImpl(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (!controller.signal.aborted) render(data);
} catch (error) {
if (!controller.signal.aborted) reportError(error);
} finally {
if (current === controller) current = undefined;
}
},
cancel() {
current?.abort();
current = undefined;
},
};
} Test the response order you do not want
The accompanying test uses manually resolved promises, not arbitrary sleeps. It starts A, starts B, resolves B, and only then resolves A. The fake transport deliberately ignores abort. The observed render list contains B alone, and the signal attached to A is aborted.
A second pair makes the older request fail after the newer one starts. That obsolete error is suppressed, while a current HTTP 503 reaches reportError. A final case cancels the loader before a successful response arrives; the response does not update the view. These are deterministic ownership tests, not measurements of network performance.
The complete minimal test is available below. Put it beside latest-request.mjs and run it with Node.js 22.23.2. With the final signal check removed, the rendered array includes the stale response and the assertion fails. Keep the fake transport cancellation-blind: otherwise the transport could hide a broken UI guard.
- Assert the rendered value, not just whether abort() was called.
- Test an obsolete rejection as well as an obsolete success.
- Test cleanup while response parsing or other awaited work is unfinished.
latest-request.test.mjs
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createLatestLoader } from './latest-request.mjs';
test('a late response cannot replace the current result', async () => {
const pending = [];
const rendered = [];
const loader = createLatestLoader({
render: data => rendered.push(data),
reportError: error => { throw error; },
// Deliberately ignore abort to test the final commit guard.
fetchImpl: () => new Promise(resolve => pending.push(resolve)),
});
const first = loader.load('/search?q=a');
const second = loader.load('/search?q=ab');
pending[1](new Response(JSON.stringify({ query: 'ab' })));
await second;
pending[0](new Response(JSON.stringify({ query: 'a' })));
await first;
assert.deepEqual(rendered, [{ query: 'ab' }]);
}); node --test latest-request.test.mjs Keep cleanup and error handling at the view boundary
Call cancel() when the owning view is destroyed. In a routed application, this means using the component or route cleanup hook rather than waiting for the browser tab to close. If a framework HTTP client already handles request cancellation when its subscription is disposed, use that established path instead of maintaining two competing request owners.
Do not silently discard every error named AbortError. The example checks the controller owned by this request, so cancellation is tied to an actual lifecycle decision. An active request failure still gets a visible recovery path. render and reportError should be small synchronous callbacks; asynchronous transformations belong before the final ownership check.
Decide what stays visible while the next request runs
Correct ordering still leaves a product decision: should changing a filter clear the results immediately, or keep the previous results visible until the replacement arrives? Clearing avoids presenting stale information as current, but can make a list jump. Keeping the list gives continuity, provided the UI clearly marks it as updating and prevents actions that depend on obsolete filters.
Whichever option you choose, let the same request owner control the loading and error states. A late finally block from A must not hide B's loading indicator. A late failure from A must not replace B's successful view with an error. Those are the same race with different output fields, not separate networking problems.
At unmount, cancel the request and detach the input listener. On remount, create a new loader for the new view. Keeping a shared loader alive across unrelated screens creates invisible coupling: a request in one screen can cancel work owned by another. Test each independently replaceable view as a separate owner.
| Concern | Useful policy | What it does not solve |
|---|---|---|
| Fast repeated input | Debounce before starting a request | Response ownership |
| Old network work | Abort the previous request | Server-side rollback |
| Old UI work | Check ownership before committing | Payload validation |
| Component teardown | Cancel and remove event listeners | A durable mutation workflow |
Where this pattern stops helping
Use this loader for a replaceable read. Cancelling an order request does not establish that the server stopped creating the order. Durable mutations need server-side status, authorization, and duplicate handling rather than a newer-input-wins rule.
Before adopting the example, connect cancel to view destruction, validate the response shape, and decide whether previous results remain visible while loading. Then exercise that integration in the real browser. The deterministic fixture proves the ownership rule under its supplied transport; it does not test your framework teardown or every browser's fetch implementation.


