Most frontend bugs aren’t dramatic crashes - they’re races. The user types “airbag” into a search box. If not configured properly, six separate fetches go out, one per keystroke. The slowest one (let’s say the one for “air”) arrives last, after all the others. Your UI now confidently shows results for “air” instead of “airbag”. Nothing has failed, every request succeeded; but the user sees the wrong thing.

AbortController is the browser’s answer to this. It lets you cancel an in-flight fetch (or any operation that accepts an AbortSignal) and have it reject cleanly with an AbortError. Combined with React’s effect cleanup, it makes the race-condition class of bugs almost trivially solvable.


An AbortController has two parts:

  1. The controller: the thing you keep around to trigger the abort.
  2. The signal: a read-only AbortSignal you pass into operations that should be cancellable.
const controller = new AbortController();

fetch("/api/search?q=airbag", { signal: controller.signal })
  .then((res) => res.json())
  .then((data) => console.log(data))
  .catch((err) => {
    if (err.name === "AbortError") {
      console.log("request was cancelled, that's fine");
    } else {
      throw err;
    }
  });

// Later, somewhere:
controller.abort();

Calling abort() causes the in-flight fetch to immediately reject with an AbortError. The browser also tears down the underlying network request - it’s not just the JS Promise that’s cancelled, the actual TCP socket gets closed.

The pattern: create a controller, pass its signal to your async work, call abort when you don’t want the result anymore.

A search-as-you-type, properly

Here’s the bug from the intro, fixed:

let currentController: AbortController | null = null;

async function search(query: string) {
  // Cancel any previous in-flight request
  currentController?.abort();

  const controller = new AbortController();
  currentController = controller;

  try {
    const res = await fetch(`/api/search?q=${query}`, {
      signal: controller.signal,
    });
    return await res.json();
  } catch (err) {
    if ((err as Error).name === "AbortError") return null;
    throw err;
  }
}

Now if the user types five characters quickly, only the last fetch survives. The earlier four are killed at the network level the moment a new one starts.

The React idiom - abort on unmount

The most common cause of “Can’t perform a React state update on an unmounted component” warnings is a fetch that resolves after the component has gone. AbortController plus useEffect cleanup makes it a non-issue:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => res.json())
      .then(setUser)
      .catch((err) => {
        if (err.name !== "AbortError") console.error(err);
      });

    return () => controller.abort();
  }, [userId]);

  return user ? <p>{user.name}</p> : <p>Loading…</p>;
}

Two things to notice -

  1. The userId is in the dependency array, so React will re-run the effect (and abort the old fetch) the moment userId changes. Race conditions cleaned up automatically.
  2. The cleanup function calls controller.abort() when the component unmounts.

Composing signals: AbortSignal.any()

Sometimes you want a fetch to abort if either the user navigates away or a timeout elapses. Modern browsers have AbortSignal.any() which combines multiple signals:

const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000); // built-in!

fetch("/api/slow", {
  signal: AbortSignal.any([userController.signal, timeoutSignal]),
});

AbortSignal.timeout() is a one-liner replacement for the old setTimeout + controller.abort() dance. It’s the single most reached-for new browser API I’ve adopted in the last year.

A reusable React hook

Here’s the abstraction I found somewhere on the internet for non-trivial cases - a hook that gives me a stable signal that auto-aborts on unmount, plus a restart() function for fresh attempts:

import { useEffect, useRef, useCallback } from "react";

export function useAbortController() {
  const controllerRef = useRef<AbortController | null>(null);

  const restart = useCallback(() => {
    controllerRef.current?.abort();
    controllerRef.current = new AbortController();
    return controllerRef.current.signal;
  }, []);

  useEffect(() => {
    controllerRef.current = new AbortController();
    return () => controllerRef.current?.abort();
  }, []);

  return { signal: controllerRef.current?.signal, restart };
}

Used like:

function Search() {
  const [query, setQuery] = useState("");
  const { restart } = useAbortController();

  useEffect(() => {
    if (!query) return;
    const signal = restart();
    fetch(`/api/search?q=${query}`, { signal }).then(/* ... */);
  }, [query, restart]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Other things that accept an AbortSignal

fetch is the obvious one, but the same primitive works on a surprising amount of browser/Node APIs:

  • addEventListener(type, fn, { signal }): listener auto-removed when signal aborts. Hugely underused. No more manual removeEventListener cleanup!
  • navigator.locks.request(name, { signal }, fn): Web Locks API.
  • stream.pipeTo(dest, { signal }): readable streams.
  • Node’s fs.readFile(path, { signal }) and most other fs/timers APIs.
  • Most third-party libraries that follow the convention: Tanstack Query, fetch-based clients, axios with axios.get(url, { signal }).

The addEventListener one in particular is a quiet revolution. Compare:

// Old: pair the add and the remove
function setup() {
  window.addEventListener("scroll", onScroll);
  window.addEventListener("resize", onResize);
  document.addEventListener("keydown", onKey);
  return () => {
    window.removeEventListener("scroll", onScroll);
    window.removeEventListener("resize", onResize);
    document.removeEventListener("keydown", onKey);
  };
}

// New: one signal, multiple listeners, aborted together
function setup() {
  const ctrl = new AbortController();
  window.addEventListener("scroll", onScroll, { signal: ctrl.signal });
  window.addEventListener("resize", onResize, { signal: ctrl.signal });
  document.addEventListener("keydown", onKey, { signal: ctrl.signal });
  return () => ctrl.abort();
}

One controller, three listeners, all torn down together. The cleanup function shrinks to a single line regardless of how many listeners you add.

Common mistakes

  • Forgetting the AbortError check. The fetch rejects when aborted. If you don’t check err.name === "AbortError", you end up showing the user an error message for an action they triggered.
  • Reusing a controller after abort(). Once a controller has aborted, its signal is permanently aborted. You need a fresh new AbortController() for the next request.
  • Forgetting to pass the signal to nested calls. If your fetch handler then calls JSON.parse(await res.text()), the text() call also accepts the original signal-aware response - so a long stream of bytes won’t keep arriving after abort.
  • Aborting too aggressively in development. React 18 strict mode runs effects twice on mount. The first run’s controller gets aborted immediately. If your fetch logs an error on every abort, dev console gets noisy. Filter on AbortError.

Closing

AbortController is a very useful API for aborting or cancelling asynchronous operations. Race conditions in the search box, leaked listeners after navigation, stale fetches after unmount - all of them collapse into “create a controller, pass its signal, call abort on cleanup”.

The pattern is small enough to fit in your head. The wins are big enough to justify it on every async boundary.

I would also recommend MDN reference and exmaples, it’s worth the read: AbortSignal.