Diego Betto's Blog
Photo by Will Porada

August 29, 2026 · 4 min di lettura

AbortController: how to cancel a fetch in JavaScript

How to use AbortController and AbortSignal to cancel fetch requests and other async operations, avoiding race conditions and wasted network calls.

Condividi:XLinkedInFacebookWhatsApp

A search box with autocomplete that hits an API on every keystroke is the textbook example, but the problem is more general than that: any fetch you can trigger multiple times before the previous one finishes — a filter that changes, a tab the user reopens, a component that unmounts while the request is still in flight — has the same latent bug. Responses can arrive in a different order than the one they were sent in, and if you update state with the last response received instead of the last one requested, the user briefly sees the wrong result.

AbortController exists exactly for this: it gives JavaScript a standard way to tell an already-started async operation “I don’t care about your result anymore, stop.”

The problem, concretely

async function searchProducts(query) {
  const res = await fetch(`/api/search?q=${query}`);
  const data = await res.json();
  renderResults(data); // which request wins?
}

input.addEventListener("input", (e) => searchProducts(e.target.value));

Type “app”, then right after “apple”. Two fetches go out. If the request for “app” — maybe because the server happens to be slower on that specific query — responds after the one for “apple”, renderResults gets called twice and the last call wins: the screen ends up showing results for “app”, even though the user typed “apple” a while ago. It’s not an error that throws an exception, it’s a purely visual, silent bug, hard to reproduce reliably — the kind of bug that goes ignored for months because “it happens sometimes, no one knows why.”

The fix: one controller per request

let currentController = null;

async function searchProducts(query) {
  currentController?.abort(); // cancel the previous request, if any
  currentController = new AbortController();

  try {
    const res = await fetch(`/api/search?q=${query}`, {
      signal: currentController.signal,
    });
    const data = await res.json();
    renderResults(data);
  } catch (err) {
    if (err.name === "AbortError") return; // canceled on purpose, not a real error
    console.error("Search failed", err);
  }
}

Every new call aborts the previous call’s controller before creating a new one. When abort() is called, fetch rejects its promise with an AbortError — which is why the catch block explicitly recognizes it and simply returns, instead of treating it as a real network error.

⚠ Attenzione

fetch() rejects, it doesn’t resolve, when canceled: a common mistake is forgetting that AbortError goes through catch, not a check inside the try block. If your code doesn’t distinguish AbortError from other network errors, you end up showing the user a “Connection error” message every time they simply type one more character — a bug just as annoying as the one you were trying to fix.

Not just fetch: any API that accepts a signal

AbortSignal isn’t specific to fetch — it’s become a standard pattern that many native web APIs accept:

// An event listener that removes itself
document.addEventListener("click", handler, { signal: controller.signal });

// A cancelable timer without keeping the id around for clearTimeout
setTimeout(() => console.log("done"), 1000 /* not natively, needs a wrapper */);

setTimeout has no direct native signal support, but the pattern integrates easily:

function cancelableWait(ms, signal) {
  return new Promise((resolve, reject) => {
    const id = setTimeout(resolve, ms);
    signal.addEventListener("abort", () => {
      clearTimeout(id);
      reject(new DOMException("Canceled", "AbortError"));
    });
  });
}

The same AbortController can then cancel multiple operations connected to the same signal at once — useful when a single event (the user navigates away, a component unmounts) needs to stop several things at once, without manually tracking each of them.

Automatic cancellation with a timeout

A practical use worth knowing: AbortSignal.timeout() creates a signal that cancels itself after N milliseconds, useful for never leaving a fetch hanging indefinitely on a slow network or an unresponsive server:

const res = await fetch("/api/slow-data", {
  signal: AbortSignal.timeout(5000), // cancels automatically after 5 seconds
});

You can also combine multiple signals with AbortSignal.any() — the request cancels as soon as any one of the provided signals fires, useful for merging “the user canceled manually” and “the timeout expired” into the same fetch without handling them separately:

const signal = AbortSignal.any([userController.signal, AbortSignal.timeout(5000)]);

The connection to debounce and throttle

If the problem you’re solving is “the user types too fast and I generate too many requests,” AbortController and debounce aren’t alternatives, they’re complementary and solve two different problems: debounce reduces how many requests go out over time (it waits for a pause in typing before calling the API); AbortController guarantees that, of the requests that go out anyway, only the last one actually counts. A well-built search field, in practice, almost always uses both together.

💡 Consiglio

It’s not just for network fetches: the pattern applies any time you have an async operation whose result can become irrelevant before it completes — including non-network cases, like an IntersectionObserver attached to a component that unmounts, or a stopPropagation/preventDefault call on an event that might never arrive if the element is removed first. If you already handle event propagation carefully, AbortSignal is the same kind of discipline applied to async code instead of DOM events.

Condividi:XLinkedInFacebookWhatsApp