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