Skip to content
R.

Stop stale fetch requests from replacing newer results

Use AbortController and a final result check to prevent an older request from overwriting the current view, with a reproducible race test.

4 min readComments

A user changes a filter twice. The second request finishes first, so the screen shows the right results. Then the first request arrives and replaces them. Both responses can be valid; the bug is that the view no longer knows which request owns the result.

The fix has two parts: cancel work that is no longer useful, and refuse to commit a result after its request has been superseded. This example implements that boundary without a framework dependency. It was checked with Node.js 22.23.2 on September 9, 2026; browser integration still needs a test in the application that owns the view.

Give each request its own cancellation signal

Create a fresh AbortController for every load. Aborting a controller also changes the state of its signal; that signal is not a reusable reset switch. Pass it to fetch so the transport and response-body work can observe cancellation.

Keep the controller in the view owner, not in a shared global variable. A search panel and a comment list should not cancel each other merely because they use the same HTTP helper. The lifetime is one independently replaceable piece of UI.

SourcesMDN: AbortSignal (opens a new tab)

Cancel the old request and guard the commit

load() aborts the previous controller before starting the next request. After parsing the response, it checks the signal again before calling render(). That final check is useful when a test double ignores cancellation, or when additional asynchronous work is introduced between the network response and the UI update.

The finally block clears current only if it still belongs to that invocation. An older request finishing later must not erase the newer request controller. The same ownership rule applies to loading indicators: an old request should not clear the spinner for a new one.

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.

  • 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.

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.

Cancellation is not a server-side rollback

This pattern is for replacing read results. Do not use it as the consistency strategy for creating an order, granting access, or sending an email. A cancelled client request does not prove that the server stopped processing a mutation. Those operations need their own durable status and duplicate handling.

The example omits retries, caching, deadlines, and payload validation to keep the race visible. Add them deliberately around this boundary and keep the result-ownership tests. A request being successful and a request still being relevant are separate questions.

Share LinkedIn Email

Discussion

Leave a comment

Comments appear after review. No email needed.