Cancel an in-flight fetch with AbortController
Canonical URL: https://devexamples.com/javascript/cancel-a-fetch-request-with-abortcontroller/
The problem
A search-as-you-type field fires several requests, and the slowest one lands last. Without cancellation the oldest result wins, and unmounting the view leaves the handler writing into state that no longer exists.
Short answer
Keep one AbortController per logical request, abort the previous controller before starting the next, and treat AbortError as an expected outcome rather than a failure.
Cancellation is a per-request capability, so the controller belongs to the request, not to the page.
let current = null;
async function search(term) {
// Abandon whatever the previous keystroke started.
current?.abort();
const controller = new AbortController();
current = controller;
try {
const response = await fetch(`/api/suggest?q=${encodeURIComponent(term)}`, {
signal: controller.signal,
});
if (!response.ok) return { ok: false, status: response.status };
const data = await response.json();
// A newer request replaced us while we were awaiting; keep its result.
if (controller.signal.aborted) return { stale: true };
return { ok: true, data };
} catch (error) {
if (error.name === 'AbortError') {
// Expected: we or the caller cancelled. Nothing to report to the reader.
return { ok: false, reason: 'aborted' };
}
return { ok: false, reason: 'network', message: error.message };
}
}// A timeout that is also cancellable by hand combines two signals.
const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(4000)]);
try {
const response = await fetch('/api/slow', { signal });
} catch (error) {
if (error.name === 'TimeoutError') {
// The timeout fired.
} else if (error.name === 'AbortError') {
// controller.abort() fired, or the composed signal aborted for another reason.
}
}Explanation
AbortController exposes a signal that fetch observes. Calling abort() rejects the pending
promise with a DOMException named AbortError and, in current browsers, ends the underlying
connection rather than merely discarding the result. That distinction matters for a search
field: cancelling avoids both the wasted server work and the late answer that would otherwise
overwrite the newer list.
Ordering is why the controller is created per request and stored in a variable the closure
captures. Aborting the previous controller before starting a new one means at most one request
is live. Checking signal.aborted after the await handles the remaining race: a request that
finished normally but was superseded while you were awaiting it must not render. Reading the
captured controller rather than the shared current variable is what makes that check correct,
because current may already point at the next request.
AbortError must be separated from genuine failures. If it reaches a generic error branch, a
user who typed one more character sees a network-error message for a request they cancelled
themselves. The same reasoning applies to timeouts: AbortSignal.timeout() rejects with
TimeoutError, not AbortError, so a timeout-specific message is possible without inspecting
the error text.
Assumptions
AbortSignal.any()andAbortSignal.timeout()require a current browser generation; both are available in Chromium, Firefox, and Safari releases from 2024 onward.AbortSignal.any([signal])on an aborted member rejects the composed wait with the reason of whichever member aborted first, which is why the two error names are checked separately above.
Common mistakes
- Creating one controller for the whole component and aborting it more than once, after which every later request inherits an already-aborted signal and fails instantly.
- Swallowing all errors in the
AbortErrorbranch, which also hides a real cancellation of a request the reader never asked to stop. - Forgetting to clear
currenton completion, so a later abort targets a finished request.
Caveats
- Aborting after the response body has started streaming stops the read but does not undo any server-side side effect; a cancelled
POSTmay already have been applied. - An aborted request still counts against the browser’s per-host connection limits until the rejection is delivered, so aborting a hundred parallel requests is not a substitute for limiting concurrency.
Prerequisite
Prerequisite
Fetch JSON and separate a failed request from a failed statusRead a response and its failure kinds before deciding to abort one.
Examples that treat this as a prerequisite
Derived at build time from the editorial graph; no edge is invented here.
- Type a generic fetch hook that returns a known payload
Cancel a pending request with AbortController first; this hook's cleanup depends on that control.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Type a generic fetch hook that returns a known payloadThe typed hook exposes the same lifecycle control as a reusable component API.
Same task or topic
Fetch JSON and separate a failed request from a failed statusSolves the Fetch API data task
Same task or topic
Narrow an unknown API response with type guardsShares the Fetch and HTTP topic
References
- AbortController(opens in a new tab) — MDN. Documents the controller-and-signal pair used to cancel requests.
- AbortSignal.timeout()(opens in a new tab) — MDN. Describes the built-in timeout signal and how to combine it with manual abort.
Source page: https://devexamples.com/javascript/cancel-a-fetch-request-with-abortcontroller/