AniUI Academy

Cancelling Requests and Handling Stale Results

AbortController, AbortSignal.timeout, AbortSignal.any — and the race-safety pattern for making sure the UI shows the latest result, not whichever fetch happened to finish last.

11 min read

The fetching-data lesson earlier in this track covered the happy path: call fetch(url), await the response, parse the body, show it. That's enough for a one-off load. Real applications need two more things that lesson deliberately deferred: a way to cancel an in-flight request, and a way to make sure the response the UI acts on is the response to the latest request, not whichever fetch happened to finish last.

Both problems have one modern answer: AbortController and the AbortSignal family of APIs.

AbortController: how cancellation actually works

Every fetch accepts a signal option, which is the listener side of a cancel. You create the two sides with AbortController:

const controller = new AbortController();
 
const promise = fetch("/api/users", { signal: controller.signal });
 
// Later, somewhere else, when you no longer want the result:
controller.abort();

What happens on .abort():

  • The browser stops reading from the network.
  • The fetch Promise rejects with a DOMException whose name is 'AbortError'.
  • Any response.json() or body-stream read already in progress on that response rejects the same way.

That rejection is how you tell "I cancelled this on purpose" apart from "the network failed":

try {
  const res = await fetch(url, { signal: controller.signal });
  return await res.json();
} catch (err) {
  if (err.name === "AbortError") return;  // cancelled on purpose
  throw err;                                // real failure
}

The "return nothing" in the abort branch is deliberate: if you cancelled the request, the caller isn't waiting for an answer anymore. Throwing would just surface a cancellation as an error.

AbortSignal.timeout: the self-cleaning timeout

The classic "cancel this request after 5 seconds" pattern used to look like this:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
  const res = await fetch(url, { signal: controller.signal });
  clearTimeout(timer);
  return await res.json();
} catch (err) {
  clearTimeout(timer);
  throw err;
}

Correct, but noisy. If you forget the clearTimeout on the happy path, the controller sits around waiting for a timer that still fires harmlessly. If you forget it on the error path, same thing. AbortSignal.timeout() is the one-liner that replaces all of it:

const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

The signal fires automatically after 5000ms, the fetch rejects with a TimeoutError (name 'TimeoutError', same shape as AbortError), and there's no timer to clean up because the browser is doing the bookkeeping. Reach for this whenever you'd otherwise set up a one-off setTimeout to abort.

AbortSignal.any: composing multiple reasons to cancel

Sometimes a request has more than one reason to be cancelled:

  • The user navigated away.
  • The request is taking too long.
  • A parent component unmounted.
  • The user pressed Escape.

Each of those might have its own signal. AbortSignal.any([...]) combines them: the returned signal fires as soon as any of the inputs fires.

const timeout = AbortSignal.timeout(10_000);
const signal = AbortSignal.any([effectSignal, timeout]);
 
const res = await fetch(url, { signal });

Without it, you'd wire up multiple addEventListener("abort", ...) listeners and manage them yourself. With it, composition is one line.

The stale-result race

The second problem — making sure the UI shows the latest result — is the one that bites more quietly than cancellation. Picture a search input:

input.addEventListener("input", async () => {
  const query = input.value;
  const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
  const results = await res.json();
  renderResults(results);
});

The user types "react" quickly: five keystrokes, five fetches. Network latency is not uniform — a request for rea might finish after a request for reac. The last renderResults call wins, which means the UI can display the results for rea even though the user is now on react. This is the stale-result race, and it's one of the most common invisible bugs in JavaScript UI code.

Fix 1: cancel the previous request

Cancellation is the preferred fix because it also saves work. Keep a controller alive, abort it before firing the next request:

let active = null;
 
input.addEventListener("input", async () => {
  active?.abort();                        // cancel the previous one
  active = new AbortController();
 
  const query = input.value;
  try {
    const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
      signal: active.signal,
    });
    const results = await res.json();
    renderResults(results);
  } catch (err) {
    if (err.name !== "AbortError") throw err;
  }
});

Now only the latest fetch can reach renderResults. The earlier ones rejected the moment the next keystroke fired.

Fix 2: capture and compare

Sometimes cancellation isn't an option — maybe you're waiting on a computation that can't be aborted, or you're reading from a source without AbortSignal support. Fall back to capturing the state you're racing against:

let latest = 0;
 
input.addEventListener("input", async () => {
  const token = ++latest;
  const query = input.value;
 
  const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
  const results = await res.json();
 
  if (token !== latest) return; // a newer request has started; ignore this one
  renderResults(results);
});

The monotonically-increasing latest is the tell: when a response arrives, its token has to still equal the current latest or it's been superseded. This pattern composes cleanly with libraries that don't speak AbortSignal and is the ground truth under what React's useEffect cleanup, SWR's isLagging, and TanStack Query's queryKey versioning are all solving at a higher level.

Putting it together: a reusable search hook

The real-world shape of all this, in one function:

async function search(query, { signal }) {
  const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
    signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
  });
  if (!res.ok) throw new Error(`search failed: ${res.status}`);
  return res.json();
}
 
// Call site:
let active = null;
async function onInput(query) {
  active?.abort();
  active = new AbortController();
  try {
    renderResults(await search(query, { signal: active.signal }));
  } catch (err) {
    if (err.name === "AbortError" || err.name === "TimeoutError") return;
    throw err;
  }
}

Every piece of this is doing one job: the timeout caps the worst case, the controller cancels when the user types again, and the caller only paints the result for the latest in-flight request.

What to remember

  • AbortController + signal cancels an in-flight fetch. The Promise rejects with name === "AbortError", which is what you check for in a catch.
  • AbortSignal.timeout(ms) is the modern one-liner for "cancel this request after ms milliseconds" — no timer to clean up, rejects with name === "TimeoutError".
  • AbortSignal.any([signals]) combines multiple reasons to cancel — unmount, user navigation, timeout — into one signal.
  • Network ordering is not guaranteed. A later request can finish before an earlier one, so the UI can show a stale result unless you defend against it.
  • Prefer cancellation (controller.abort() before firing the next request). Fall back to a monotonic token or captured state when cancellation isn't possible.
  • The reusable shape: wrap your fetch helper to accept a signal, pass in a composed signal at the call site, cancel on the next invocation.

Check yourself

4 questions · pass 3/4 to unlock Scope and Closures

up to 50
  1. 1.What does calling controller.abort() actually do to an in-flight fetch?

  2. 2.What is AbortSignal.timeout(5000) and why is it easier than wiring up a setTimeout plus a controller by hand?

  3. 3.A search input fires a fetch on every keystroke. The user types 'react'; before the response for 'react' arrives, a response for 'reac' arrives *later* because of network timing. Without cancellation, what can go wrong, and what's the fix?

  4. 4.What does AbortSignal.any([a, b, c]) do, and when is it the right tool?

4 left to answer

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.