Cancellation with AbortController
Stop fetch requests, timers, event listeners, and your own asynchronous work with AbortController and AbortSignal.
- 01Separate controller from signalKeep the power to abort with the caller and pass the read-only signal to work.
- 02Cancel browser work safelyAbort fetches, body reads, timeouts, and event listeners without leaking timers.
- 03Design cancellable APIsAccept
{ signal }, check up front, listen once, clean up, and reject with the original reason.
Why cancellation exists
Asynchronous JavaScript lets work continue while your page stays responsive. That is wonderful until the work is no longer useful: the user typed a newer search, moved to another page, pressed Stop, or a request took too long. Cancellation is the pattern for telling that old work, “please stop now, clean up what you started, and reject the promise so the caller can move on.”
You have already met promises, timers, and error handling strategies. Cancellation connects all three: an operation receives a signal, checks it, listens for it, and rejects with a reason when the signal aborts.
Imagine ordering fries, then realizing you are at the wrong table. You can ask the waiter to cancel. If the kitchen has not started, nothing happens. If the fries are already cooked, cancelling your wait does not uncook them. That is how browser requests work too: you can stop waiting, but you cannot assume the server forgot.
- In real life: You tell the waiter to cancel
- In JavaScript: Code calls
controller.abort() - In real life: The kitchen stops if it can
- In JavaScript: The task clears timers, listeners, or a request
- In real life: Food already cooked is still cooked
- In JavaScript: Server-side effects are not automatically undone
Where the analogy stops: A restaurant may choose to refund you. JavaScript cancellation only delivers a signal; every API decides what cleanup it can safely do.
AbortController creates an AbortSignal; passing that signal into asynchronous work lets the owner abort work that is no longer needed.
AbortController & AbortSignal
INTERACTIVEThe API is split into two objects because ownership matters. The AbortController has the power: abort(reason?). The AbortSignal is what work receives: it exposes aborted, reason, an abort event, and throwIfAborted(). Hand out the signal; keep the controller.
A good referee does not hand the whistle to every player. The players only need to listen. In code, the same rule prevents helper functions from accidentally cancelling their caller’s other work.
- In real life: The referee owns the whistle
- In JavaScript: The caller owns
new AbortController() - In real life: Players listen for the whistle
- In JavaScript: Tasks receive
controller.signal - In real life: One whistle blast stops play
- In JavaScript: A signal aborts once and stays aborted
Where the analogy stops: A human referee can restart play with the same whistle. An AbortSignal cannot reset; create a new controller for the next operation.
Sort these responsibilities before you run the bigger demo.
- Has the
.abort(reason?)method - Passed to
fetch(url, { signal }) - Exposes
.abortedand.reason TimeoutErrorfromAbortSignal.timeout(1000)- Cannot be reset after aborting
- Usually kept private by the caller
Decide who owns each cancellation responsibility.
Now try a fake chunked download. It uses timers instead of a real network so you can see each chunk, but it follows the same pattern as a cancellable request: check first, pass the signal down, clear pending work when the signal aborts, and reject with the signal’s reason.
async function downloadFile({ signal }) { signal.throwIfAborted(); for (const chunk of [1, 2, 3, 4]) { await wait(350, { signal }); progress.value = chunk * 25; } return "download complete";}- Press Start to create a fresh controller.
A fresh operation gets a fresh controller. Start normally, give it a deadline, or combine a manual abort with a timeout.
Notice the reason names. controller.abort() without a custom reason creates a DOMException named AbortError in modern platforms. AbortSignal.timeout(ms) aborts with a DOMException named TimeoutError. A signal aborts only once, and the abort event fires synchronously during abort().
Cancelling fetch
BROWSER CHECKfetch is the most common place you will use a signal. You pass it in the options object: fetch(url, { signal }). If the signal aborts before the request completes, modern fetch rejects with signal.reason. Older browsers often reported AbortError for every abort, so production code should care about the broad category more than exact messages.
const signal = AbortSignal.abort(); try { await fetch(location.href, { signal });} catch (error) { console.log(error.name);}The signal is already aborted, so fetch rejects before doing useful work. This demo uses location.href, not an external URL.
Aborting after response headers arrive can still matter: if your code is reading a large body stream, aborting can make the body read fail. What abort does not do is roll back a server. If you sent a request that creates an order, the server may already have created it.
A user pressing Stop is usually not an error worth alarming them about. In a catch, check error.name or the signal state so you can ignore expected cancellation and still show real network failures.
AbortSignal.timeout & AbortSignal.any
ALARMSYou do not always need to create a controller yourself. AbortSignal.abort(reason?) returns a signal that is already aborted. AbortSignal.timeout(ms) returns a signal that aborts itself after a deadline. AbortSignal.any([...]) creates a combined signal that follows the first input signal to abort. The any helper is Baseline 2024 in browsers and available in modern Node.
A deadline signal is an egg timer that blows the whistle for you. A combined signal is like placing several alarms around the room: as soon as one rings, you wake up and the reason says which one rang.
- In real life: An egg timer rings after one minute
- In JavaScript:
AbortSignal.timeout(1000)aborts itself - In real life: Any alarm can wake you
- In JavaScript:
AbortSignal.any([user, deadline])follows the first abort - In real life: The first ringing alarm explains why you woke
- In JavaScript: The combined signal keeps the first reason
Where the analogy stops: Real alarms can be snoozed. AbortSignals are one-shot; build a new signal for another deadline.
const alreadyDone = AbortSignal.abort(new DOMException("Nope", "AbortError")); const deadline = AbortSignal.timeout(1000); const controller = new AbortController();const cancelOrDeadline = AbortSignal.any([controller.signal, deadline]);Use timeout when slow work should fail automatically. Use any when several events can stop the same operation: user navigated away, component unmounted, or a deadline expired.
Making your APIs cancellable
STEP THROUGHBuilt-in APIs are useful, but your own helpers should cooperate too. The convention is to accept an options object with signal. That keeps the function extensible: later you can add retries, timeout, or other options without changing the call shape.
export function sleep(ms, { signal } = {}) { signal?.throwIfAborted(); return new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timer); reject(signal.reason); }; const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve("slept"); }, ms); signal?.addEventListener("abort", onAbort, { once: true }); });} const controller = new AbortController();const promise = sleep(800, { signal: controller.signal });controller.abort(new DOMException("Stopped", "AbortError"));Choose a setting, predict whether sleep starts a timer, then step through the real cancellation pattern.
script
export function sleep(ms, { signal } = {}) { signal?.throwIfAborted(); return new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timer); reject(signal.reason); }; const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve("slept"); }, ms); signal?.addEventListener("abort", onAbort, { once: true }); });} const controller = new AbortController();controller.abort(new DOMException("Stopped", "AbortError"));The pattern is small but strict:
- Check up front with
signal?.throwIfAborted(). - Start the real work only after that check.
- Add an abort listener with
{ once: true }. - On abort, clean up and reject with
signal.reason. - On success, remove the listener and resolve normally.
Search-as-you-type race condition
INTERACTIVESearch boxes often fire a request for every few characters. If the request for c is slow and the request for cat is fast, the old c result can finish last and overwrite the fresh result. That is not a promise bug; it is a race condition.
let currentController; async function runSearch(query) { currentController?.abort(new DOMException("Newer search", "AbortError")); currentController = new AbortController(); const { signal } = currentController; const results = await fakeSearch(query, { signal }); render(results);}- Run the preset typing sequence: c → ca → cat.
No abort means the slow first query can finish last and overwrite the fresh result. That is the classic search-as-you-type race.
The fix is to keep the previous controller in a variable. Before starting a newer request, abort the previous controller and create a new one. Only the latest request gets to render.
Abort can remove event listeners too
REAL BUTTONaddEventListener accepts a signal option. When the signal aborts, the browser removes that listener. This is a tidy way to clean up temporary UI, drag handles, keyboard shortcuts, or pop-up behavior that attaches several listeners at once.
const controller = new AbortController();const { signal } = controller; button.addEventListener("click", countClick, { signal });button.addEventListener("pointerenter", showHint, { signal });window.addEventListener("keydown", onKey, { signal }); controller.abort(); // removes all three listenersThe demo button and the C key have listeners registered with one shared signal.
{ signal } option.Where you’ll use this
Cancellation shows up anywhere the user can outpace old work:
- Autocomplete and search-as-you-type requests.
- Route changes: stop loading data for pages the user left.
- Upload, download, import, and export Stop buttons.
- Deadlines for slow operations.
- Temporary event listeners that should disappear together.
let currentController; export async function loadProfile(userId) { currentController?.abort(new DOMException("Newer profile", "AbortError")); currentController = new AbortController(); try { const response = await fetch("/api/users/" + userId, { signal: currentController.signal, }); return await response.json(); } catch (error) { if (currentController.signal.aborted) return null; throw error; }}Misconceptions and edge cases
HONESTY CHECK| Idea | What is true | Common trap |
|---|---|---|
| Abort vs undo | Abort stops waiting and local work. | It does not roll back server-side effects. |
| AbortError vs TimeoutError | The reason tells you why the signal aborted. | Do not assume every abort has the same name. |
| Controller vs signal | The controller aborts; the signal notifies. | Passing controllers everywhere gives too much power. |
| Promise rejection vs cleanup | Rejecting reports the stop to callers. | You still need to clear timers and listeners. |
- “Promises can be cancelled.” Promises report completion. The work behind them must cooperate with a signal.
- “Abort undoes everything.” It does not undo effects already completed, especially on a server.
- “I can reuse one controller.” Create a new controller per operation because a signal aborts only once.
- “Timeouts are just AbortError.” Timeout signals use
TimeoutErrorin modern platforms. - “If fetch responded, abort no longer matters.” Reading the body stream can still error after abort.
Practice exercises
5 EXERCISESRead the code and type the exact error name printed.
const signal = AbortSignal.abort(new DOMException("Too slow", "TimeoutError"));
try {
signal.throwIfAborted();
} catch (error) {
console.log(error.name);
}The code prints TimeoutError. The signal’s reason is a DOMException with that name, and throwIfAborted() throws it.
Complete the helper so callers can pass { signal }.
function delay(ms, { signal } = {}) {
// Your code here
}function delay(ms, { signal } = {}) {
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}This follows the full pattern: guard first, listen once, clear the timer on abort, reject with the original reason, and remove the listener on success.
What text is left on screen after these two renders?
let rendered = "";
function render(value) { rendered = value; }
render("Results for cat");
render("Results for c");
console.log(rendered);The final text is Results for c. That is the bug: old work rendered after newer work. Abort the old request before starting the new one.
Predict the printed reason name.
const controller = new AbortController();
controller.abort();
controller.abort(new DOMException("Later", "TimeoutError"));
console.log(controller.signal.reason.name);It prints AbortError. Calling abort() the first time creates the default AbortError reason; the later TimeoutError is ignored.
Sketch the shape of a helper that only lets the latest search win.
let currentController;
async function searchLatest(query) {
// abort the old one, create a new one, pass the signal to fetch
}let currentController;
async function searchLatest(query) {
currentController?.abort(new DOMException("Newer search", "AbortError"));
currentController = new AbortController();
const { signal } = currentController;
try {
const response = await fetch("/search?q=" + encodeURIComponent(query), { signal });
return await response.json();
} catch (error) {
if (signal.aborted) return null;
throw error;
}
}Each call cancels the previous search, creates a fresh one-shot controller, passes the signal down, and treats expected aborts as a neutral null result.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which object should you pass to a function that should be cancellable but should not control cancellation?
Choose an answer to see the explanation.
Question 2 of 7What does the aborted boolean snippet print?
Read the code, then predictconst controller = new AbortController(); controller.abort(); console.log(controller.signal.aborted);Choose an answer to see the explanation.
Question 3 of 7What is the default reason when
controller.abort()gets no reason?Choose an answer to see the explanation.
Question 4 of 7What does the custom reason snippet print?
Read the code, then predictconst signal = AbortSignal.abort(new DOMException("Too slow", "TimeoutError")); try { signal.throwIfAborted(); } catch (error) { console.log(error.name); }Choose an answer to see the explanation.
Question 5 of 7What happens when
AbortSignal.any([a, b])is used?Choose an answer to see the explanation.
Question 6 of 7What does the one-shot abort snippet print?
Read the code, then predictconst controller = new AbortController(); controller.abort(); controller.abort(new DOMException("Late", "TimeoutError")); console.log(controller.signal.reason.name);Choose an answer to see the explanation.
Question 7 of 7Why should a cancellable API remove listeners or clear timers?
Choose an answer to see the explanation.
Key takeaways
- Keep the
AbortController; pass theAbortSignal. - A signal aborts once and keeps its first reason.
- Modern fetch rejects with the signal’s reason; handle aborts separately from real failures.
AbortSignal.timeoutcreates deadlines;AbortSignal.anycombines ways to stop.- Cancellable APIs check up front, listen once, clean up, reject with
signal.reason, and pass signals down.
Cancellation is cooperative: an owner aborts a signal, and well-designed work notices, cleans up, and rejects with the reason.
Up next: Async patterns.