ESC

Type to search the knowledge base.

Cancelable Async Tasks

Wrap work so a caller can abort: AbortController, ignored stale results, and the difference between cancelling IO and ignoring a resolved promise.

intermediate6 min read
  • javascript
  • abortcontroller
  • async
  • interview

Search types "rea", then "react". Two fetches are in flight. The slower "rea" response can land last and paint stale rows. A cancelable async wrapper with a canceled flag does not stop the request. It only refuses to use the payload. The network tab still shows both calls, the server still did the work, and a setState after unmount is one missed if away. AbortController is what actually aborts fetch.

Interviewers want you to name that gap. Cancelling IO means the host actually stops: fetch with an AbortSignal, clearTimeout, xhr.abort(), a worker terminate. Dropping the result means the work runs to completion and you ignore it. Both are valid. They are not the same API.

AbortController is the platform primitive. A homemade makeCancelable(promise) is a filter on settlement. Say which one you are building before you type.

Key takeaways

  • Abort stops (or tries to stop) the underlying IO. A boolean on a wrapper only drops the result.
  • Pass signal into fetch and into your own timers. Listen for abort to clearTimeout.
  • Treat AbortError as control flow. Do not toast it as a failure.
  • A generation counter is fine for “last write wins” UI. It does not cancel the previous HTTP call.

Abort the IO, or drop the result, pick one and say it

Two contracts show up in interviews. Pick one and implement it honestly.

Approach What stops What the promise does Typical use
AbortController fetch, streams, anything that takes signal Rejects with AbortError Search, unmount, Stop
Ignore / generation Nothing Fulfills or rejects as usual; caller skips Cheap local work, no abort handle
makeCancelable wrap Nothing Rejects with a sentinel if flagged Legacy React blog pattern

The wrap looks clever and teaches the wrong lesson:

function makeCancelable(promise) {
  let canceled = false;

  const wrapped = new Promise((resolve, reject) => {
    promise.then(
      (value) => {
        if (canceled) reject(Object.assign(new Error('Canceled'), { canceled: true }));
        else resolve(value);
      },
      (err) => {
        if (canceled) reject(Object.assign(new Error('Canceled'), { canceled: true }));
        else reject(err);
      },
    );
  });

  return {
    promise: wrapped,
    cancel() {
      canceled = true;
    },
  };
}

The original promise still settles. The socket stays open. Callers now have a third failure shape (err.canceled) to forget. If you show this, immediately say it is an ignore-flag, not cancellation.

The abort version owns a controller and forwards the signal:

function cancelableFetch(url, options = {}) {
  const controller = new AbortController();
  const signal = options.signal
    ? AbortSignal.any([options.signal, controller.signal])
    : controller.signal;

  const promise = fetch(url, { ...options, signal }).then((res) => {
    if (!res.ok) throw new Error(String(res.status));
    return res.json();
  });

  return {
    promise,
    abort(reason) {
      controller.abort(reason);
    },
  };
}

const req = cancelableFetch('/api/search?q=react');
req.abort(); // fetch rejects with AbortError; the browser aborts the request

AbortSignal.any is the right merge when the caller already has a signal (timeout, unmount) and you still want a local Stop. If AbortSignal.any is off the table, abort your inner controller from the outer abort listener.

Dropping results without abort

Sometimes the work cannot abort: a pure compute, a library with no handle, a promise you did not create. Then you ignore stale generations.

function createLatest() {
  let gen = 0;

  return async function run(task) {
    const mine = ++gen;
    const value = await task();
    if (mine !== gen) return { stale: true, value };
    return { stale: false, value };
  };
}

const latest = createLatest();
latest(() => fetch('/a').then((r) => r.json()));
const second = latest(() => fetch('/b').then((r) => r.json()));
// first response is marked stale even if it wins the race

Use this for “last keystroke wins” after you have aborted, or when abort is impossible. Do not sell it as cancelling IO.

Timers are the easy abort you should not skip. A sleep that ignores signal keeps the chain alive after unmount:

function delay(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
      return;
    }
    const id = setTimeout(resolve, ms);
    signal?.addEventListener(
      'abort',
      () => {
        clearTimeout(id);
        reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
      },
      { once: true },
    );
  });
}

Same pattern for wrapping a Node-style API you promisified: if the library has no cancel, abort can only reject your wrapper; the library keeps going.

Wiring search, unmount, and timeout

Production shape for typeahead:

  1. Abort the previous controller.
  2. Create a new AbortController.
  3. Pass signal into fetch (and into delay/retry).
  4. Catch AbortError and return.
  5. On unmount, abort once more.
let controller = null;

async function search(query) {
  controller?.abort();
  controller = new AbortController();

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });
    if (!res.ok) throw new Error(String(res.status));
    return await res.json();
  } catch (err) {
    if (err.name === 'AbortError') return null;
    throw err;
  }
}

Timeout is not Promise.race against a timer you forget to clear, and race does not abort the loser. That helper lives on timeout a promise. Prefer AbortSignal.timeout(ms) or AbortSignal.any([user.signal, AbortSignal.timeout(ms)]) so the fetch itself aborts.

React: abort in the effect cleanup. Strict Mode will abort the first mount’s request on purpose; a fresh controller per run makes that harmless. See useEffect fundamentals.

Retries must not retry AbortError. User cancel is not a flake. See retry a promise.

Footguns

  • Swallowing every error in catch. Filter err.name === 'AbortError' (or err === signal.reason).
  • Reusing one controller. After abort(), the signal stays aborted. Next request needs a new controller.
  • Aborting the client and assuming the server rolled back. The browser stops listening. The handler may still commit unless it also watches the request abort.
  • Rejecting with { canceled: true }. Callers using instanceof Error miss it. Prefer DOMException / AbortError.
  • Leaving the original promise unreferenced after wrap. It still holds the result in memory until it settles.

Interview answer (out loud)

“Cancel means two different things. If the API accepts an AbortSignal, I pass one and call abort so fetch actually stops, and I treat AbortError as control flow. If I only have a promise, I can ignore a stale generation, but the work still runs. I would not ship makeCancelable as cancellation. For search, each keystroke aborts the previous controller. For unmount, the effect cleanup aborts. Race against a timer is not abort unless the loser also gets a signal.”

Further reading

Related guides