Async JavaScript & Timing
Async JavaScript & Timing

Cancellation & races: AbortController and latest-only requests

Full · ~40 min
After this unit you can
  • Thread an AbortSignal through your own async functions: accept { signal }, check throwIfAborted(), reject with signal.reason, and stop the underlying work
  • Combine a cancel with a deadline using AbortSignal.any() and AbortSignal.timeout() (naming their support versions), and tell a cancellation, a timeout and a real failure apart
  • Fix a last-response-wins race with an abort plus a latest-request check, and say what each one covers
  • Explain what aborting can't do: un-send a write, order updates on the server, or stop code that ignores the signal

Read

Simple
1 / 2

A controller and its signal

Flag

Promises have no built-in "cancel". The platform's answer is a pair of objects:

  • AbortController, the button. Whoever decides to cancel keeps it and calls abort().
  • AbortSignal (controller.signal), the wire. You pass it to the work. The work watches it and stops when it fires.
const controller = new AbortController();

fetch("/api/report", { signal: controller.signal })
  .then((res) => res.json())
  .then(showReport)
  .catch((err) => {
    if (err.name === "AbortError") return; // we cancelled it: not a failure
    showError(err);
  });

cancelButton.onclick = () => controller.abort();

When abort() is called, fetch stops the request and its promise rejects with an AbortError DOMException. If the response headers have already arrived, reading the body (res.json(), res.text()) rejects instead.

Four facts about the pair:

  • Aborting is one-way and once. The first abort() sets signal.aborted = true and fires the abort event. Later calls do nothing, and the signal stays aborted forever.
  • So use one controller per operation. A signal that's already aborted makes every new fetch reject immediately. Create a fresh controller for each request you might cancel.
  • signal.reason says why. With no argument it's an AbortError DOMException. abort(someValue) makes someValue the reason, and fetch rejects with exactly that value.
  • Cancellation is cooperative. A signal does nothing by itself. It only stops work that takes it and listens to it. fetch does. Your own async functions must be written to (medium tier).

Medium
1 / 2

Threading a signal through your own functions

Flag

fetch honours a signal because it was written to. Your own async functions only become cancellable if you write them the same way. The DOM Standard spells out the contract for any promise-returning API that can be aborted: take the signal as a signal option, reject immediately if it's already aborted, and otherwise reject with signal.reason when it aborts.

Pass it down to everything that waits.

async function loadReport(id, { signal } = {}) {
  const res = await fetch(`/api/reports/${id}`, { signal });
  const report = await res.json(); // also aborted by the same signal
  await sleep(250, signal);        // your own wait: make it abortable too
  signal?.throwIfAborted();        // a checkpoint before expensive CPU work
  return summarise(report);
}

Each step that doesn't receive the signal is a hole: an abort during that step goes unnoticed until the next step that does check.

Make your own waits abortable.

function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted(); // already aborted: reject right away
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort); // done: stop listening
      resolve();
    }, ms);
    function onAbort() {
      clearTimeout(timer);   // stop the work itself…
      reject(signal.reason); // …and settle with the caller's reason
    }
    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

The details carry the weight:

  • throwIfAborted() first. A listener added to a signal that's already aborted never fires, so without this check the promise would wait out the whole delay.
  • reject(signal.reason), not new Error("aborted"). The caller chose the reason (an AbortError by default, a TimeoutError from AbortSignal.timeout, or their own value). Pass it through unchanged so their checks still work.
  • Stop the work, not just the promise. clearTimeout (or abort on an inner request) is the part that saves resources. Rejecting alone just means nobody waits for the result.
  • Remove the listener when you finish normally. A long-lived signal (one per page, say) would otherwise collect a listener from every call.

Complex
1 / 2

Latest-only requests — abort and an identity check, together

Flag

A reusable wrapper that makes any signal-taking request "latest only". Each call aborts the one before it, adds an optional deadline, and reports the outcome as data, so callers never need a try/catch to tell "superseded" from "failed":

function latestOnly(request, { timeoutMs } = {}) {
  let current; // the controller of the newest call

  return async function run(...args) {
    current?.abort();                    // 1. the previous call is stale now
    const controller = new AbortController();
    current = controller;
    const deadline = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
    const signal = deadline ? AbortSignal.any([controller.signal, deadline]) : controller.signal;

    try {
      const value = await request(...args, signal);
      if (controller !== current) return { status: "superseded" }; // 2. finished, but too late
      return { status: "ok", value };
    } catch (error) {
      if (controller !== current) return { status: "superseded" }; // 3. aborted by a newer call
      if (deadline?.aborted) return { status: "timeout" };         // 4. our deadline passed
      return { status: "error", error };
    }
  };
}

const search = latestOnly(
  (q, signal) => fetchJson(`/search?q=${encodeURIComponent(q)}`, { signal }),
  { timeoutMs: 5000 },
);
const outcome = await search("rea");
if (outcome.status === "ok") render(outcome.value);

Why both mechanisms? The abort (1) stops the network work, but it can only stop steps that take the signal. Anything else in request still runs to completion: a cache lookup, a third-party SDK call, a JSON.parse of a big payload, an await enrich(data) that ignores the signal. And an abort can't un-fulfil a promise that has already fulfilled. The identity check (2, 3) is what guarantees that only the newest call's result is ever used, whatever the request does with its signal.

Why compare controllers instead of checking error.name? "Was this superseded?" is a question about our bookkeeping, not about the error. A newer call might abort with a custom reason, and a request may wrap the abort in its own error type. The controller identity answers it exactly. "Did it time out?" is bookkeeping too: keep the timeout signal and ask whether it aborted (4). A wrapped error has no TimeoutError name to find.

Superseded is not an error. Return it as its own status, and have the UI ignore it. Shown as an error, every fast typist sees a "Search failed" flash.

Quiz

A search box keeps one AbortController for the whole page and calls controller.abort() before each new search, passing controller.signal to fetch. From the second keystroke on, every search fails instantly. Why?

Flag

Your sleep(ms, signal) adds an abort listener that clears the timer and rejects. Called with a signal that's already aborted, it waits the full ms and then resolves. Why?

Flag

A client cancels with controller.abort(new Error("superseded")), and its catch ignores errors where err.name === "AbortError". What does the user see when a search is superseded?

Flag

Why prefer fetch(url, { signal: AbortSignal.timeout(5000) }) over Promise.race([fetch(url), rejectAfter(5000)])?

Flag

A request uses signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]). Which catch logic tells a user cancel, a timeout and a real failure apart, even if someone aborts with a custom reason?

Flag

A search aborts the previous request on every keystroke. But results come from sdk.query(q), which takes no signal. What can still go wrong?

Flag

A DELETE /projects/42 request is aborted 50 ms after it was sent. What do you know about project 42?

Flag

Fill in the blank

Make this request cancellable by the controller.

Flag
const res = await fetch(url, { : controller.signal });

When the signal aborts, stop the timer and reject with whatever reason the caller chose.

Flag
signal.addEventListener("abort", () => {
  clearTimeout(timer);
  reject(signal.);
}, { once: true });

Give one request two ways to stop: the controller, or a 5-second deadline.

Flag
const signal = AbortSignal.([controller.signal, AbortSignal.(5000)]);

sdk.query can't be cancelled. Make sure only the newest query's results are rendered.

Flag
const id = ++latestId;
const results = await sdk.query(q);
if (id  latestId) return; // a newer query has started since
render(results);

Show a timeout message, but only for a deadline set with AbortSignal.timeout().

Flag
if (err.name === "") showMessage("Search timed out");

Predict the output

const controller = new AbortController();
controller.signal.addEventListener("abort", () => console.log("abort event"));

controller.abort("first");
controller.abort("second");

console.log(controller.signal.aborted, controller.signal.reason);
Flag
const controller = new AbortController();
const request = fetch("https://api.example.com/search?q=re", { signal: controller.signal });
controller.abort(new Error("superseded"));

try {
  await request;
} catch (err) {
  console.log(err.name, "|", err.message, "|", err.name === "AbortError");
}
Flag
let latest = 0;
const shown = [];

async function onQuery(q, ms) {
  const id = ++latest;
  const results = await new Promise((resolve) => setTimeout(() => resolve(q), ms));
  if (id !== latest) return;
  shown.push(results);
}

onQuery("r", 90);
onQuery("re", 30);
setTimeout(() => onQuery("rea", 10), 50);
setTimeout(() => console.log(shown), 200);
Flag

Challenge

Make the search client latest-only

Flag

createSearchClient() backs a search-as-you-type box. The UI calls search(query) on every (debounced) change and cancel() when the box is cleared, and renders getState(): { query, status, results, error }.

search-api.ts is the backend (read-only). Like fetch, it takes an AbortSignal and rejects with signal.reason when that signal aborts. The client's timeoutMs option defaults to TIMEOUT_MS from config.ts, which the tests use (the preview passes a longer one).

On a fast connection the client looks fine. On a real one it has four problems:

  • Every keystroke's request runs to completion, even after a newer query has replaced it.
  • Responses arrive in random order, and whichever arrives last is shown, so the box can say "rea" while showing the results for "r".
  • Clearing the box shows "Search failed": the cancellation is reported as an error. Fast typists would see the same flash on every superseded search, once requests are aborted.
  • A request that hangs leaves the box on "loading" forever.

Your task

Edit search-client.ts so the tests pass:

  • A search shows its results, with status: "success"
  • Starting a new search aborts the previous request's signal
  • An older response never overwrites a newer search's results, even when the request ignores its signal
  • A superseded search is never reported as an error, even when the request rejects with its own error instead of an AbortError
  • cancel() aborts the request in flight and returns to status: "idle" with no error, and stays idle even if that request still resolves
  • A search that takes longer than timeoutMs (default TIMEOUT_MS) has its request aborted, and ends with status: "error" and error: "Search timed out", even when the request wraps the abort in its own error
  • A real failure ends with status: "error" and the failure's own message

Loading the in-browser editor…

Further reading