Engineering Notes
Home/Languages/JavaScript/Cancellation / AbortController
Languages · JavaScript

Cancellation / AbortController

Once a Promise is created, there is no built-in way to "cancel" it — it will eventually settle regardless of whether you still care about the result. AbortController provides a standard, separate signaling mechanism that async operations can choose to observe and stop early.

💻 Languages● Advanced⏱ 5 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • The Problem: Promises Can't Be Cancelled Directly
  • Basic Usage
  • signal.aborted and the abort Event
  • Using AbortSignal in Your Own Async Functions
  • AbortSignal.timeout() — Built-in Timeout Signals

1The Problem: Promises Can't Be Cancelled Directly

Once a Promise is created, there is no built-in way to "cancel" it — it will eventually settle regardless of whether you still care about the result. AbortController provides a standard, separate signaling mechanism that async operations can choose to observe and stop early.

2Basic Usage

js
const controller = new AbortController();
const signal = controller.signal;

fetch("/api/data", { signal })
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === "AbortError") {
      console.log("Request was cancelled");
    } else {
      console.log("Request failed:", err.message);
    }
  });

controller.abort(); // triggers cancellation — fetch rejects with an AbortError

AbortController doesn't force-stop code that's already running synchronously — it signals "please stop" to APIs that specifically check for it (fetch, and any custom code you write to respect it).

3signal.aborted and the abort Event

js
const controller = new AbortController();
const { signal } = controller;

console.log(signal.aborted); // false

signal.addEventListener("abort", () => {
  console.log("Received abort signal, reason:", signal.reason);
});

controller.abort("user cancelled"); // reason is optional, defaults to an AbortError
console.log(signal.aborted); // true

4Using AbortSignal in Your Own Async Functions

js
function cancellableDelay(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal.aborted) {
      reject(new DOMException("Already aborted", "AbortError"));
      return;
    }
    const timeoutId = setTimeout(resolve, ms);
    signal.addEventListener("abort", () => {
      clearTimeout(timeoutId); // stop the pending work
      reject(new DOMException("Aborted", "AbortError"));
    });
  });
}

Any custom async function can accept a signal parameter and check signal.aborted (for already-cancelled cases) or listen for the "abort" event (for cancellation that happens mid-flight) — this is the standard pattern for making your own code cancellable.

5AbortSignal.timeout() — Built-in Timeout Signals

js
fetch("/api/data", { signal: AbortSignal.timeout(5000) })
  .catch(err => {
    if (err.name === "TimeoutError") console.log("Request took too long");
  });

A modern shortcut that creates a signal which auto-aborts after the given number of milliseconds — no manual setTimeout + controller.abort() boilerplate needed.

6Practical Use Case: Cancelling a Stale Request (Search-as-you-type)

js
let currentController = null;

async function search(query) {
  if (currentController) currentController.abort(); // cancel the PREVIOUS in-flight request
  currentController = new AbortController();

  try {
    const res = await fetch(`/search?q=${query}`, { signal: currentController.signal });
    return await res.json();
  } catch (e) {
    if (e.name === "AbortError") return null; // expected — an old request was superseded
    throw e;
  }
}

Without cancellation, rapid typing could cause an earlier (slower) search's response to arrive after a later, faster one — showing stale results. Aborting the previous request eliminates this race condition entirely.

Under the HoodAbortSignal's Real Internal State and Dispatch Timing▾

AbortSignal is a real EventTarget with two internal slots

AbortSignal extends the same EventTarget base class that DOM nodes and countless other browser/runtime objects use for event dispatch. Per spec, it carries two internal slots: [[AbortReason]] (holds whatever value .abort(reason) was called with, defaulting to a fresh AbortError) and an internal abort flag that signal.aborted simply reads.

text
new AbortController()
  → creates a fresh AbortSignal with abort flag = false, [[AbortReason]] = undefined

.abort() fires its event synchronously, not as a task

js
controller.abort("done");
console.log("this line runs AFTER every abort listener has already fired");
abort() synchronously sets the aborted flag and reason, then immediately fires the abort event, all before returning
abort() synchronously sets the aborted flag and reason, then immediately fires the abort event, all before returning

Unlike setTimeout callbacks or Promise reactions (which are always deferred to a queue — see the Event Loop and Microtasks topics), calling .abort() runs through its algorithm synchronously, immediately, in place: set the abort flag, set [[AbortReason]], then synchronously dispatch the "abort" event to every registered listener, one after another, all before .abort() itself returns control to whatever called it. This is why a signal.addEventListener("abort", ...) handler is guaranteed to have already run by the time the line after controller.abort() executes — there's no queuing delay to reason about here, unlike almost every other async-adjacent mechanism covered in this stack.

Why fetch integration doesn't need polling

js
fetch(url, { signal });

fetch doesn't periodically check signal.aborted in a loop — it registers its own internal "abort" listener on the signal at request start, exactly like the cancellableDelay pattern from section 4. When .abort() fires synchronously, that listener runs immediately as part of the same synchronous dispatch, which is what lets fetch reject its pending Promise essentially instantly rather than on some later polling interval.

Why a signal can only ever abort once

The internal abort flag, like a Promise's [[PromiseState]] (Promises topic), is a one-way transition — once set to true, calling .abort() again is a no-op (the reason and event don't fire a second time). This is the concrete reason section 7's advice to create a new AbortController per cancellable operation isn't just convention — a signal genuinely cannot be "reset" and reused; its internal state permanently reflects the first abort, if any.

⚠️ Common Pitfalls

  1. Assuming abort() stops code instantly, everywhere — it only affects code that explicitly checks signal.aborted or listens for the "abort" event; unrelated synchronous code keeps running regardless.
  2. Reusing a controller after calling .abort() — once aborted, a controller's signal stays aborted forever; create a new AbortController for each new cancellable operation.
  3. Forgetting to remove abort event listeners in long-lived code — can cause memory leaks or duplicate handling if the same signal is reused (though a single controller/signal is normally single-use).
  4. Not distinguishing AbortError from real failures in a .catch() — treating a deliberate cancellation as an actual error can trigger unwanted error UI/logging.

📋 Quick Reference

text
const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort();                        → triggers cancellation
signal.aborted                              → boolean
signal.addEventListener("abort", handler)     → react to cancellation
AbortSignal.timeout(ms)                        → built-in auto-abort after ms

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is AbortController?core▾

A standard API to cancel async operations: create a controller, pass controller.signal to APIs (fetch, addEventListener, streams, many libraries), and call controller.abort(reason); the operation rejects with an AbortError.

Follow-ups interviewers ask
  • Does it work in Node? Yes, globally available.
Q2How do you add a timeout to fetch?core▾

fetch(url, { signal: AbortSignal.timeout(5000) }), or combine signals with AbortSignal.any([userSignal, AbortSignal.timeout(5000)]).

Follow-ups interviewers ask
  • Error name on timeout? TimeoutError (vs AbortError for manual aborts).
Q3How do you make your own functions abortable?core▾

Accept a signal option, check signal.aborted/call signal.throwIfAborted() at checkpoints, listen to the abort event to clean up (clear timers, close resources), and pass the signal down to nested calls.

Follow-ups interviewers ask
  • Why pass it down? Cancellation should propagate through the whole operation.
Q4How is AbortController used in React?senior▾

Create a controller in an effect, pass its signal to fetch, and abort in the cleanup so outdated requests don't update state after dependencies change or unmount.

Follow-ups interviewers ask
  • Handle the AbortError? Ignore it, since it's expected.
Q5Can one signal remove many event listeners?core▾

Yes: addEventListener(type, fn, { signal }) removes the listener when the signal aborts, making cleanup of multiple listeners a single call.

Follow-ups interviewers ask
  • Use case? Component teardown.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.