Skip to content
R.

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.

About 8 min readComments

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.

Both search fields contain ab. The unguarded view shows the older Animation field notes result for a, while the guarded view keeps Abstract layout guide for ab.
Controlled browser-rendered fixture driven by createLatestLoader. Identical input and B-then-A response order; only the guarded view keeps the current result. No customer data or measured network delays.

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.

The broken approach / JavaScript
async function loadResults(query) {
  const response = await fetch(`/search?q=${encodeURIComponent(query)}`);
  const result = await response.json();
  render(result); // Whichever response finishes last wins.
}

The same requests, two different outcomes
EventUnguarded viewOwned result
A startsWaiting for AA owns the view
B startsWaiting for A and BB owns the view; A is cancelled
B completesB is visibleB is visible
A completes lateA incorrectly replaces BA 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.

SourcesMDN: AbortSignal (opens a new tab)

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.

latest-request.mjs / JavaScript
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
latest-request.test.mjs / JavaScript
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' }]);
});

Run beside latest-request.mjs / Shell
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.

Choose behavior deliberately
ConcernUseful policyWhat it does not solve
Fast repeated inputDebounce before starting a requestResponse ownership
Old network workAbort the previous requestServer-side rollback
Old UI workCheck ownership before committingPayload validation
Component teardownCancel and remove event listenersA 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.

Share LinkedIn Email Subscribe

Discussion

Leave a comment

Comments appear after review. No email needed.

Image preview

Follow the blog

Choose email updates or your favorite RSS reader.

The weekly email

New articles on code, design, and AI. Fridays, only when there is something new.

By subscribing, you agree to weekly blog emails.
Confirm in your inbox. Unsubscribe anytime. Privacy

Use OpenRSS

Preview the feed, then choose a reader to subscribe.

Open in OpenRSS (opens a new tab)

Already have a reader?

Paste this link into your reader's Add feed option.

Prefer a reader? RSS does not require an email address. View XML feed