AbortController & Cancellation

The Course
•
4 min read

The caller owns a cancellation signal

new AbortController() creates a controller with a signal property. Pass the signal to an operation such as fetch using { signal: controller.signal }. Calling abort asks participating work to stop. It is a request, not a magic interruption of arbitrary JavaScript. A custom operation must cooperate with the signal.

Check signal.aborted when starting, because the signal may already be cancelled. For future cancellation, listen for its abort event. Remove listeners and clear timers when settling so completed work does not retain unnecessary callbacks. A controller’s signal cannot be reset; use a new controller for a new operation.

Browser fetch normally rejects with an AbortError when aborted without a custom reason. The small function below models that behavior with an Error whose name is set explicitly. Your UI should treat intentional cancellation differently from “service unavailable.” Ordinary failures should remain visible.

Cancellation and correctness are related but separate. A service might ignore the signal or finish just before abort. On Day 5 we will also prevent stale results from replacing a newer search. Cancelling saves work when possible; a current-request check protects the displayed result.

Predict and trace

Write the output before running. Follow the values through each call; if your prediction differs, identify the first step that surprised you.

Predict, then run JavaScript
async function main() {
  function abortError() {
    const error = new Error("Cancelled");

    error.name = "AbortError";
    return error;
  }

  async function read(signal) {
    if (signal.aborted) {
      throw abortError();
    }

    return "Ready";
  }

  const controller = new AbortController();

  controller.abort();

  try {
    await read(controller.signal);
  } catch (error) {
    console.log(error.name);
  }

  console.log(controller.signal.aborted);
}

main();
Check your prediction
AbortError
true

This example cancels before work starts. The final demo service also listens for abort while its timer is pending.

Cancel work that is already pending

The earlier async read settles immediately. Now keep a promise pending with a timer and cancel it after starting. The signal emits an abort event. The same onAbort function is registered and removed; creating a different callback would not remove the original listener. No timing guess is needed: abort happens synchronously before a timer task can run.

Cancel work that is already pending JavaScript
async function main() {
  function waitForTitle(signal) {
    return new Promise((resolve, reject) => {
      function onAbort() {
        clearTimeout(timer);
        signal.removeEventListener("abort", onAbort);

        const error = new Error("Cancelled");

        error.name = "AbortError";
        reject(error);
      }

      const timer = setTimeout(() => {
        signal.removeEventListener("abort", onAbort);
        resolve("Notes");
      }, 0);

      if (signal.aborted) {
        onAbort();
      } else {
        signal.addEventListener("abort", onAbort, { once: true });
      }
    });
  }

  const controller = new AbortController();
  const pending = waitForTitle(controller.signal);

  controller.abort();

  try {
    console.log(await pending);
  } catch (error) {
    console.log(error.name);
  }
}

main();
Check your prediction
AbortError

The { once: true } listener option removes the listener after its first event. Success also removes it, and cancellation clears the timer. The timer variable is assigned before onAbort can run; declaring a function does not execute its body.

Build it yourself

Exercise

AbortController & Cancellation: practise

Write read(signal) which rejects an already-aborted signal with Error(“Stopped”) and otherwise returns “Ready” asynchronously. Print the result with a fresh signal and catch the aborted case.

Your working space JavaScript
// The two attempts need separate controllers.
Hint 1: the idea

Inspect the signal, not an unrelated boolean.

Hint 2: a first step

An async throw becomes a rejected promise that the caller awaits.

Show one solution
async function main() {
  async function read(signal) {
    if (signal.aborted) {
      throw new Error("Stopped");
    }

    return "Ready";
  }

  const first = new AbortController();

  console.log(await read(first.signal));

  const second = new AbortController();

  second.abort();

  try {
    await read(second.signal);
  } catch (error) {
    console.log(error.message);
  }
}

main();
Ready
Stopped

Call abort twice and observe that the signal stays aborted. Explain why reusing that signal for a retry would immediately reject.

Test a changed requirement

Call abort twice and observe that the signal stays aborted. Explain why reusing that signal for a retry would immediately reject.

Using the pending example, remove only the caller’s abort line and rerun: it should fulfill with Notes. Then create a fresh controller, abort before calling waitForTitle, and verify rejection still occurs. Those are three different schedules, not three different validation rules.

Explain before moving on

Why might a current-request check still be needed after abort? Who decides that cancellation is intentional?

Going further

See MDN’s AbortSignal reference for the signal API. The exercises use explicit controllers and listeners so the ownership and cleanup are visible.

Was this article helpful?

Your response is saved on this device.