Topic: Web platform

AbortController: Cancel Stale Work, Not Every Error

AbortController stops work a person has superseded, but cancellation, timeouts, HTTP failures, and server-side effects need different handling. Put controller ownership in the right place first.

Animated meme (expand/collapse)
When a search query or route has changed, an old request should not keep someone waiting or arrive late and replace the newer screen. · Source: GIPHY

AbortController is often treated as a timeout accessory for fetch: create a controller, call abort() after five seconds, and ignore AbortError in a catch block. That can run, but it misses the real question: who decides that this async work no longer has value?

That owner is usually a user intent or a UI lifetime, not fetch. A search changes from rea to react, a component unmounts, or one route is replaced by another. Those are clear points at which earlier work becomes stale. The scope that knows this owns the controller; downstream functions receive a signal and cooperate.

One user intent, one controller

A typeahead needs more than debounce. Debounce reduces request count, but it cannot stop a rea request that has already left the browser. That request can still arrive after react and put stale results back on screen.

let activeSearch;

async function search(query) {
  activeSearch?.abort(new DOMException("Superseded", "AbortError"));

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

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });

    if (!response.ok) throw new Error(`Search failed: ${response.status}`);
    const results = await response.json();

    controller.signal.throwIfAborted();
    if (activeSearch !== controller) return;
    renderResults(results);
  } catch (error) {
    if (controller.signal.aborted) return;
    showSearchError(error);
  } finally {
    if (activeSearch === controller) activeSearch = undefined;
  }
}

The point is not to put every async operation behind a controller. It is to align the controller’s scope with an intent. An aborted signal cannot be reset, so each new search needs a new controller. The finally block also clears only the controller it still owns; otherwise an old request can erase newer state when it finishes.

One signal can travel through a chain of work that supports it. Fetch, response-body reads, some event listeners, and custom Promise APIs can cooperate. Pure synchronous work and long CPU loops still need throwIfAborted() at interruption points, or a Worker. AbortController does not magically terminate arbitrary JavaScript.

Cancellation, timeout, and failure are different outcomes

Silently cancelling an old search after a new keystroke is sensible. A search that has not returned after four seconds may deserve a retry message. HTTP 500 is different again: fetch still gives the caller a Response, so code must check response.ok.

async function loadSuggestions(query, userSignal) {
  const timeout = AbortSignal.timeout(4_000);
  const signal = AbortSignal.any([userSignal, timeout]);

  try {
    const response = await fetch(`/api/suggestions?q=${encodeURIComponent(query)}`, { signal });
    if (!response.ok) throw new Error(`Suggestions failed: ${response.status}`);
    return await response.json();
  } catch (error) {
    if (userSignal.aborted) return;
    if (timeout.aborted) {
      showRetry("Search timed out. Please try again.");
      return;
    }
    throw error;
  }
}

AbortSignal.any() is useful for combining a user cancellation with a product deadline, but it keeps only the first abort reason. Retaining the original signals lets UI and logs identify which boundary happened first. AbortSignal.timeout() measures active time; when a document enters bfcache or a worker is suspended, it should not be treated as a server-latency timer.

Animated meme (expand/collapse)
Flattening timeout, HTTP failure, and deliberate cancellation into an empty list keeps the screen quiet, but leaves people and operations unable to tell what happened. · Source: GIPHY

Helpers receive signals; they do not steal controllers

When a shared helper secretly creates a global controller, its caller cannot let a route, component cleanup, or query library manage the lifetime. A smaller API treats a signal as an optional cooperative parameter:

async function fetchJSON(url, { signal } = {}) {
  signal?.throwIfAborted();

  const response = await fetch(url, { signal });
  if (!response.ok) throw new Error(`Request failed: ${response.status}`);

  const data = await response.json();
  signal?.throwIfAborted();
  return data;
}

If TanStack Query, a router, or another library already supplies a query-specific signal, pass it through. There is no value in wrapping it in an unrelated controller. A helper should create its own controller only when it owns an independent flow that a person can cancel.

Client abort does not retract a server-side effect

Stopping the browser from waiting does not prove that a server never received a POST, much less that a database transaction rolled back. For payments, order creation, or any non-repeatable effect, a controller manages the client lifetime; the server still needs idempotency keys, deduplication, and recoverable state for the possibility that a request already arrived.

That also shows when not to use AbortController. A short synchronous click handler with no later async work has nothing to cancel, so it does not need extra state. This is not a ritual for every function. It is an explicit contract for work that should stop when it becomes stale.

What I learned

  • I keep a controller with the code that understands a user intent or lifetime, while helpers and fetch calls receive signals.
  • I handle deliberate cancellation, timeout, HTTP responses, and genuine failure separately instead of using empty data to pretend everything succeeded.
  • I do not treat a client abort as a server rollback; side-effecting requests still need idempotency and recovery design.

External references