intermediate

AbortController in JavaScript: Cancel Fetch, Timeouts, and Async Work

Use AbortController and AbortSignal to cancel fetch requests, add timeouts with AbortSignal.timeout, combine signals, and write abortable async utilities.


Why cancellation is a senior topic

Promises have no built-in cancel — once started, a promise settles or hangs. But real applications must cancel constantly: the user typed another search character, navigated away, or a request exceeded its deadline. AbortController is the standard answer, and interviewers use it to separate candidates who've shipped production async code from those who've only done exercises.

The core API in 20 seconds

const controller = new AbortController();
 
fetch("/api/search?q=hello", { signal: controller.signal })
  .then((res) => res.json())
  .catch((err) => {
    if (err.name === "AbortError") return; // cancelled — usually not an error
    throw err;                              // real failure
  });
 
controller.abort(); // reject the fetch with AbortError, close the socket

One controller, one signal, many listeners. Key facts:

  • controller.abort(reason?) — aborts; signal.reason carries the reason (defaults to an AbortError DOMException).
  • signal.aborted — synchronous check; signal.throwIfAborted() — throw if already aborted.
  • A signal can be passed to multiple operations; aborting cancels them all.
  • Distinguishing AbortError from real failures in catch is the detail interviewers look for.

The search-box pattern (cancel the previous request)

The practical companion to debounce: debounce delays the call; abort kills the stale one that already left.

let controller = null;
 
async function search(query) {
  controller?.abort();                    // cancel the in-flight request
  controller = new AbortController();
 
  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });
    render(await res.json());
  } catch (err) {
    if (err.name !== "AbortError") showError(err);
  }
}

Without this, a slow response for "jav" can arrive after the response for "javascript" and clobber the UI — the classic race condition follow-up.

Timeouts: AbortSignal.timeout and AbortSignal.any

Modern platforms make the Promise.race timeout pattern one line:

// Abort automatically after 5 seconds
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
 
// Combine: whichever fires first wins (ES2024-era platform API)
const signal = AbortSignal.any([userController.signal, AbortSignal.timeout(5000)]);
const res2 = await fetch(url, { signal });

AbortSignal.any is the composition primitive: user-initiated cancel or deadline, one signal. Knowing it exists usually ends the "how would you combine signals" follow-up immediately — and being able to sketch its polyfill (listen to each inner signal, abort the outer controller once) is bonus credit.

Making your own APIs abortable

The interview exercise: "Write a sleep(ms, signal) that rejects when aborted."

function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      return reject(signal.reason);
    }
    const id = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
 
    function onAbort() {
      clearTimeout(id);
      reject(signal.reason);
    }
    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

The grading rubric hidden in this small function:

  1. Check aborted first — the signal may already be dead.
  2. Clean up both ways — clear the timer on abort, remove the listener on success. Forgetting either leaks.
  3. Reject with signal.reason, not a made-up error.

The same shape makes anything abortable: wrap the operation, subscribe with { once: true }, and unsubscribe in the success path. Add a signal parameter to a promise pool or auto-retry and you've answered their hardest follow-ups.

Abortable retry (composition example)

async function fetchWithRetry(url, { retries = 3, signal } = {}) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    signal?.throwIfAborted();                 // stop between attempts
    try {
      return await fetch(url, { signal });    // stop mid-attempt
    } catch (err) {
      if (err.name === "AbortError" || attempt === retries) throw err;
      await sleep(2 ** attempt * 100, signal); // abortable backoff
    }
  }
}

Beyond fetch

AbortSignal is now the universal cancellation token: addEventListener(type, fn, { signal }) removes listeners in bulk when you abort (the modern cleanup pattern for components), and Node.js accepts signals in fs, stream, child_process, and setTimeout from timers/promises.

Follow-ups to expect

  • "Does abort stop the server from processing?" — it closes the connection client-side; the server may still finish. Idempotency matters.
  • "Why not cancellable promises?" — TC39 rejected them (cancellation is a concern of the operation, not the promise value); tokens compose better.
  • "How do you abort a for-await loop?" — check signal.aborted per iteration, or race the iterator against an abort promise (see iterator helpers).

  • Promise.all

    intermediate

    Implement Promise.all: aggregate results in order, fail fast on the first rejection.

  • Promise.allSettled

    intermediate

    Implement allSettled: wait for every promise and report per-promise status objects.

  • Promise.any

    intermediate

    Implement Promise.any with AggregateError semantics: first success wins, all failures reject.