Cancellation with AbortController
Stop fetch requests, timers, event listeners, and your own asynchronous work with AbortController and AbortSignal.
- 01Tell the controller and the signal apartKeep the power to stop in your own code, and give the work only the signal.
- 02Stop browser work safelyStop
fetchrequests, body reads, timers, and event listeners, and leave no timers running. - 03Write your own cancellable functionsAccept
{ signal }, check first, listen once, clean up, and reject with the signal's reason.
Why cancellation exists
Some JavaScript work takes time, like loading data from a server or waiting for a timer. This is called asynchronous work: it finishes later, while the page keeps running. Sometimes that work stops being useful before it finishes. For example:
- The user typed a newer search.
- The user moved to another page.
- The user pressed a Stop button.
- The work took too long.
Cancellation means telling that old work to stop. When the work hears this, it should do three things:
- Stop as soon as it can.
- Clean up what it started, like a timer.
- Reject its promise, so the code that waits for it can move on.
You already know promises, timers, and try...catch. Cancellation uses all three. The work gets a signal: an object that says whether to stop. The work checks the signal. If the signal says stop, the work rejects its promise with a reason: a value that says why it stopped.
You order fries. Then you see that you are at the wrong table. You ask the waiter to cancel. If the kitchen has not started, no fries get made. If the fries are already cooked, cancelling does not uncook them.
- In real life: You tell the waiter to cancel
- In JavaScript: Your code calls
controller.abort() - In real life: The kitchen stops if it can
- In JavaScript: The work clears its timers, listeners, or request
- In real life: Food already cooked stays cooked
- In JavaScript: Work the server already did is not undone
Requests from the browser work the same way. You can stop waiting for an answer. But the server may have done the work already.
To abort means to stop something before it ends. An AbortController is an object with an abort() method. It makes an AbortSignal, which you find at controller.signal. You give the signal to the work. When you call controller.abort(), the signal says "stop", and the work can stop.
const controller = new AbortController();
console.log(controller.signal.aborted);
controller.abort(new DOMException("Too slow", "TimeoutError"));
console.log(controller.signal.aborted, controller.signal.reason.name);Line 1 makes a new controller. Line 2 prints controller.signal.aborted. aborted is true or false: has this signal been told to stop? It prints false, because nothing has stopped it yet.
Line 4 calls abort() and passes a reason. The reason here is a DOMException: a built-in error object that browsers use. Its first part is a message, "Too slow". Its second part is a name, "TimeoutError".
Line 5 prints true TimeoutError. The signal has stopped, and signal.reason keeps the reason you passed. A signal can stop only once. After that, it stays stopped and keeps its first reason.
abort() turns aborted from false to true and saves the reason. A signal stops only once, so it stays like this.AbortController & AbortSignal
INTERACTIVECancellation uses two objects. Each has its own job:
- The
AbortControllercan stop the work. It has the methodabort(reason). The reason is optional. - The
AbortSignalis what the work gets. It tells the work about a stop, but it can't cause one.
The signal gives the work four things:
aborted:trueonce the signal has stopped,falsebefore.reason: the value that says why it stopped.- An
abortevent: it fires the moment the signal stops, so the work can react. throwIfAborted(): a method that throws the reason if the signal has already stopped.
The rule is: keep the controller, and give out only the signal. Then other code can hear a stop, but it can't stop your work by mistake.
abort() and the signal. Give the work only the signal. Then it can hear a stop, but it can't cause one.A referee has a whistle. The players don't get whistles. They only listen for one.
- In real life: The referee owns the whistle
- In JavaScript: Your code owns
new AbortController() - In real life: Players listen for the whistle
- In JavaScript: The work gets
controller.signal - In real life: One whistle blast stops play
- In JavaScript: A signal stops once and stays stopped
In code, the same rule stops a helper function from cancelling work that it doesn't own. Sort each job into the right group before you try the bigger demo below.
- Has the
.abort(reason)method - Passed to
fetch(url, { signal }) - Has
.abortedand.reason TimeoutErrorfromAbortSignal.timeout(1000)- Can't be reset after it stops
- Usually kept private by the code that started the work
Decide which one does each job: the controller, the signal, or the reason.
Next, try a pretend download. It doesn't use the network. It uses timers, so you can watch each piece arrive. Each piece is called a chunk. The demo follows the same steps as a real cancellable request:
- Check the signal before starting.
- Pass the signal to each wait.
- When the signal stops, clear the timer that is still waiting.
- 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 make a new controller.
Each new download gets a new controller. Start it plainly, with a 1-second time limit, or with both the Abort button and a time limit.
Look at the reason names in the demo:
controller.abort()with no reason gives aDOMExceptionnamedAbortError.AbortSignal.timeout(ms)stops by itself with aDOMExceptionnamedTimeoutError.
A signal stops only once. Its abort event fires right away, during the abort() call, before the next line of code runs.
signal.abortedbecomestrueduring theabort()call. The very next line already seestrue.- A reason can be a
DOMException, anError, or any other value. So check that the reason is an object before you read itsname. abort()does nothing to work that never got the signal. The work has to listen. JavaScript can't force it to stop.- Keep the controller private. A helper that only needs to hear about a stop should get the signal, not the power to stop.
Cancelling fetch
BROWSER CHECKfetch asks a server for data and returns a promise. It is the most common place to use a signal. You pass the signal inside the options object: fetch(url, { signal }).
If the signal stops before the request finishes, the fetch promise rejects with the signal's reason. After a plain abort(), that reason is an AbortError.
const signal = AbortSignal.abort(); try { await fetch(location.href, { signal });} catch (error) { console.log(error.name);}The signal has already stopped, so fetch rejects before it does any real work. The demo fetches this page's own address, location.href, not another website.
A response comes in two parts. The headers arrive first; they describe the response. The body is the data itself, and it can keep arriving after the headers. So a stop can still matter after the headers arrive: if your code is still reading the body, that read fails.
Abort does not undo work on the server. Say your request asks the server to create an order. The server may have created it already. Stopping the request doesn't delete the order.
When a user presses Stop, that is not an error to warn them about. In your catch block, check error.name or signal.aborted. Then you can ignore an expected stop and still show real network errors.
const controller = new AbortController();
const work = new Promise((resolve) => {
setTimeout(() => resolve("finished"), 0);
});
controller.abort();
console.log(controller.signal.aborted);
work.then((value) => console.log(value));Lines 2 to 4 make a promise. Its timer resolves it with "finished". Line 6 stops the controller. Line 7 prints true, so the signal did change. But line 8 still prints finished, because the promise never got the signal. Nothing told it to listen.
This is why cancellation is called cooperative: the work has to help. It must check or listen for the signal, and it must clean up its own timers.
AbortSignal.timeout & AbortSignal.any
ALARMSYou don't always need to make a controller yourself. AbortSignal has three helpers that make a signal for you:
AbortSignal.abort(reason)gives a signal that has already stopped.AbortSignal.timeout(ms)gives a signal that stops by itself aftermsmilliseconds.AbortSignal.any([...])joins several signals into one. The joined signal stops as soon as the first of them stops.
AbortSignal.any works in all main browsers since 2024 ("Baseline 2024"), and in modern Node.
An egg timer rings by itself after a set time. You don't have to watch the clock. Now put several alarm clocks around your room. As soon as any one rings, you wake up. And you know which one woke you.
- In real life: An egg timer rings after a set time
- In JavaScript:
AbortSignal.timeout(1000)stops by itself after 1 second - In real life: Any alarm can wake you
- In JavaScript:
AbortSignal.any([user, deadline])stops when the first one stops - In real life: You know which alarm woke you
- In JavaScript: The joined signal keeps the first reason
const alreadyDone = AbortSignal.abort(new DOMException("Nope", "AbortError")); const deadline = AbortSignal.timeout(1000); const controller = new AbortController();const cancelOrDeadline = AbortSignal.any([controller.signal, deadline]);Line 1 makes a signal that has already stopped. Line 3 makes a signal that stops itself after 1000 milliseconds, which is 1 second. Line 6 joins your own controller's signal and the deadline into one signal.
const user = new AbortController();
const deadline = AbortSignal.timeout(1000);
const stop = AbortSignal.any([user.signal, deadline]);
user.abort(new DOMException("Left page", "AbortError"));
console.log(stop.reason.name);Line 3 joins the user's signal and a 1-second deadline. Line 5 stops the user's signal right away, long before the deadline. So line 6 prints AbortError, not TimeoutError.
AbortSignal.any joins both signals into stop. The user stops first, so stop keeps that reason: AbortError.When to use each one:
- Use
timeoutwhen slow work should fail by itself after a while. - Use
anywhen several things can stop the same work. For example: the user left the page, or a deadline passed.
Make a timeout signal right before the work it guards. Its clock starts when you call AbortSignal.timeout, not when a later fetch starts. If you want both a user stop and a deadline, make both signals first. Then join them with any, and pass only the joined signal to the work.
Making your APIs cancellable
STEP THROUGHBuilt-in functions like fetch accept a signal. Your own functions should too. The usual way is an options object with a signal property: sleep(800, { signal }). With an options object, you can add more options later, like retries, and the way people call the function stays the same.
The example below is sleep: a function that waits ms milliseconds and then resolves. It writes signal?. with a question mark. That is optional chaining: if no signal was passed, the call is skipped instead of throwing an error.
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"));Step through it below. Pick when the caller stops the work, then click through it one line at a time.
Pick when the caller stops the work. Guess whether sleep starts a timer, then step through the code.
script
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 promise = sleep(800, { signal: controller.signal });controller.abort(new DOMException("Stopped", "AbortError"));The pattern has five steps, in this order:
- Check first with
signal?.throwIfAborted()(line 2). If the signal has already stopped, this throws, and nothing starts. - Start the real work, here a timer (line 8).
- Listen for the
abortevent (line 12).{ once: true }removes the listener after it runs once. - On abort, clear the timer and reject with
signal.reason(lines 5 and 6). - On success, remove the listener and resolve as usual (lines 9 and 10).
const signal = AbortSignal.abort(new DOMException("Too slow", "TimeoutError"));
try {
signal.throwIfAborted();
console.log("started");
} catch (error) {
console.log(error.name);
}The signal on line 1 has already stopped, with a TimeoutError reason. So throwIfAborted() on line 4 throws that reason. Line 5 never runs, so started is never printed. The catch block prints TimeoutError on line 7.
This check stops a cancelled call before it starts a timer or a request.
function waitForTea(ms, { signal } = {}) {
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve("tea ready"), ms);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true });
});
}
const controller = new AbortController();
const tea = waitForTea(1000, { signal: controller.signal });
controller.abort();
tea.catch((error) => console.log(error.name));Line 4 starts a timer that would resolve with "tea ready" after 1 second. Line 14 stops the controller right away. The listener on lines 5 to 8 runs: it clears the timer and rejects the promise. Line 15 catches the rejection and prints AbortError, the name of the default reason.
Search as you type: old results win the race
INTERACTIVEA search box often sends a request as you type. Say the request for c is slow, and the request for cat is fast. Then the old c answer can arrive last. It replaces the newer cat results on the screen.
This bug is called a race condition: the result depends on which request finishes first. The promises work fine. The problem is the order.
In the demo, you "type" c, then ca, then cat, 120 milliseconds apart. Each search takes a fixed time, so the bug shows up every time. Run it with "Abort previous" off, then on.
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);}- Press Run typing to type c, then ca, then cat.
Nothing stops the old searches. The slow search for c finishes last and replaces the newer results. That is the race condition.
c, ca, and cat, 120 ms apart. Without abort, the slow c search finishes last and wins. With abort, only cat is left to finish.The fix is to keep the last controller in a variable. Before you start a new search, stop the old controller. Then make a new one. Only the newest search can show its results.
function fakeSearch(query, { signal }) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve("Results for " + query), 0);
signal.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true });
});
}
let currentController;
async function searchLatest(query) {
currentController?.abort(new DOMException("Newer search", "AbortError"));
currentController = new AbortController();
const { signal } = currentController;
try {
const results = await fakeSearch(query, { signal });
console.log(results);
} catch (error) {
if (signal.aborted) return;
throw error;
}
}
const oldSearch = searchLatest("cat");
const newSearch = searchLatest("cats");
Promise.all([oldSearch, newSearch]);Line 27 starts a search for cat. Line 28 starts a search for cats. That second call stops the first search on line 14. So only the newer search prints Results for cats.
Line 16 copies the signal into a local variable, signal. Each call gets its own. So on line 22, the old search checks its own signal, sees that it stopped, and quietly returns.
Debouncing means waiting a short time after the last key press before you send a request. It cuts down how many requests you send. Cancellation stops a request that has already started. A search box often uses both: wait a moment after typing, then stop the old request when a new one starts.
Abort can remove event listeners too
REAL BUTTONaddEventListener also accepts a signal option. When that signal stops, the browser removes the listener for you.
This helps when you add several listeners for a short time. For example:
- a pop-up menu,
- dragging something across the page,
- a keyboard shortcut that only works for a while.
Give all of them the same signal. Then one abort() call removes them all.
const controller = new AbortController();const { signal } = controller; button.addEventListener("click", countClick, { signal });window.addEventListener("keydown", onKey, { signal });controller.abort(); // removes both listenersThe demo button and the C key each have a listener. Both listeners use the same signal.
Where you’ll use this
Use cancellation wherever old work can stop being useful before it finishes:
- Search boxes that send a request as you type.
- Page changes: stop loading data for a page the user left.
- Stop buttons for uploads, downloads, imports, and exports.
- Time limits for slow work.
- Short-lived event listeners that should all go away together.
let currentController; export async function loadProfile(userId) { currentController?.abort(new DOMException("Newer profile", "AbortError")); currentController = new AbortController(); const { signal } = currentController; try { const response = await fetch("/api/users/" + userId, { signal }); return await response.json(); } catch (error) { if (signal.aborted) return null; throw error; }}Line 4 stops the request for the previous profile. Line 5 makes a fresh controller for this request, and line 6 keeps its signal in a local variable. Line 9 passes that signal to fetch. Line 12 returns null when this request was stopped on purpose. Line 13 throws any other error again.
Misconceptions and edge cases
COMMON MISTAKESEach row compares two ideas that are easy to mix up. Read it as: which ideas, what is true, and what mistake to avoid.
| Which ideas? | What is true | What to avoid |
|---|---|---|
| Abort or undo? | Abort stops the waiting, and stops work in your page. | Don't expect it to undo work the server already did. |
AbortError or TimeoutError? | The reason's name tells you why the signal stopped. | Don't assume every stop has the same name. |
| Controller or signal? | The controller stops the work. The signal tells the work about the stop. | Don't pass the controller around. Then any code could stop your work. |
| Reject or clean up? | Rejecting the promise tells the waiting code that the work stopped. | Rejecting doesn't clear timers or listeners. You still have to do that yourself. |
A good question to ask about any function is: "What is still running after this function stops?"
- A timer: clear it with
clearTimeout. - A listener: remove it, or add it with a signal in the first place.
- A
fetch: pass it the signal, and handle the rejection. - Work on the server: abort can't undo it. The server needs its own safety check, for example ignoring the same order when it arrives twice.
- "Promises can be cancelled." A promise only reports when work ends. The work itself must listen to a signal.
- "Abort undoes everything." It doesn't undo work that already finished, especially on a server.
- "I can reuse one controller." A signal stops only once. Make a new controller for each piece of work.
- "A timeout is just an AbortError." Timeout signals use a reason named
TimeoutError. - "Once fetch has a response, abort doesn't matter." Reading the response body can still fail after a stop.
Practice exercises
5 EXERCISESRead the code and type the exact error name it prints.
const signal = AbortSignal.abort(new DOMException("Too slow", "TimeoutError"));
try {
signal.throwIfAborted();
} catch (error) {
console.log(error.name);
}It prints TimeoutError. The signal's reason is a DOMException with that name, and throwIfAborted() throws that reason.
Finish 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 all five steps: check first, listen once, clear the timer on abort, reject with the signal's own reason, and remove the listener on success.
What text is left on the 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: the old search showed its results after the new one. Stop the old request before you start the new one.
Predict the reason name that gets printed.
const controller = new AbortController();
controller.abort();
controller.abort(new DOMException("Later", "TimeoutError"));
console.log(controller.signal.reason.name);It prints AbortError. The first abort() has no reason, so the signal gets the default AbortError. The second call, with TimeoutError, is ignored.
Sketch a helper where only the newest search can 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 stops the previous search, makes a fresh controller, and passes its signal to fetch. A stopped search returns null, which means "no result". Any other error is thrown again.
Check your understanding
7 QUESTIONSQuestion 1 of 7A function should be able to hear a stop, but not cause one. What do you pass to it?
Choose an answer to see the explanation.
Question 2 of 7What does this code print about
aborted?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 reason does
controller.abort()give when you pass no reason?Choose an answer to see the explanation.
Question 4 of 7What does this code print when the signal has its own reason?
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 does
AbortSignal.any([a, b])give you?Choose an answer to see the explanation.
Question 6 of 7What does this code print after two
abort()calls?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 function clear its timers and remove its listeners?
Choose an answer to see the explanation.
Key takeaways
- Keep the
AbortController. Give out only theAbortSignal. - A signal stops only once, and it keeps its first reason.
fetchrejects with the signal's reason. Handle a stop apart from real errors.AbortSignal.timeoutmakes a time limit.AbortSignal.anyjoins several ways to stop.- A cancellable function checks first, listens once, cleans up, rejects with
signal.reason, and passes the signal on.
Cancellation is teamwork: your code stops a signal, and the work notices, cleans up, and rejects with the reason.
Up next: Async patterns.