Lesson 63 of 80
A promise, once created, can't be cancelled from the outside. AbortController is the standard way to tell an in-flight operation 'stop, I don't need this anymore.'
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Promises don't have a .cancel() method, on purpose — once you have a promise, cancelling it would mean reaching into someone else's in-progress work and yanking it away, which gets messy fast if multiple things are watching that promise. Instead, the platform gives you a separate, purpose-built signaling mechanism: AbortController.
An AbortController has one job: hold a signal and expose an abort() method. You hand the signal to anything that supports cancellation — fetch, addEventListener, many Node APIs — and call controller.abort() when you want it to stop. The operation itself decides how to react to that signal; the controller doesn't force anything, it just broadcasts "I'm done waiting."
The classic case: a user types fast, each keystroke fires a search request, and responses can come back out of order. Without cancellation, a slow response to an old query can overwrite the fast response to a newer one, showing stale results.
let currentController = null;
async function search(query) {
// cancel whatever request is still in flight
currentController?.abort();
currentController = new AbortController();
const { signal } = currentController;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
const data = await res.json();
renderResults(data);
} catch (err) {
if (err.name === "AbortError") return; // expected — a newer search superseded this one
console.error("Search failed:", err);
}
}
input.addEventListener("input", (e) => search(e.target.value));
Every keystroke calls search again, which first aborts the previous controller before creating a new one. fetch watches the signal internally: when abort() fires, the in-flight request rejects with an AbortError, which the catch block recognizes and silently ignores — that's not a real failure, it's the expected outcome of being superseded. The result: only the most recent request's response ever reaches renderResults.
abort() doesn't stop already-finished work. If the response already arrived and .json() already resolved, aborting does nothing retroactively — cancellation only affects work that's still pending at the moment you call it.signal.aborted is permanently true — you can't "un-abort" it. Reusing it means creating a fresh AbortController, exactly like the example does on every keystroke.fetch and a handful of Web APIs check signal for you, but a plain setTimeout or a manual loop won't stop on its own — you have to check signal.aborted (or listen for the "abort" event) yourself inside that code.This is the same "signal, don't command" shape you'll see elsewhere in async JavaScript: the operation being cancelled decides how to respond, the same way a promise's consumer decides how to react to rejection rather than the rejecter reaching in and altering the consumer's code. It pairs naturally with anything built on promises — including the combinators lesson's Promise.race, which is sometimes used as a manual, ad hoc version of the same "give up waiting" idea AbortController formalizes.