Cancel an in-flight fetch with AbortController

The problem

A search-as-you-type field fires several requests, and the slowest one lands last. Without cancellation the oldest result wins, and unmounting the view leaves the handler writing into state that no longer exists.

Short answer

Keep one AbortController per logical request, abort the previous controller before starting the next, and treat AbortError as an expected outcome rather than a failure.

Cancellation is a per-request capability, so the controller belongs to the request, not to the page.

Language: JavaScript
let current = null;

async function search(term) {
  // Abandon whatever the previous keystroke started.
  current?.abort();
  const controller = new AbortController();
  current = controller;

  try {
    const response = await fetch(`/api/suggest?q=${encodeURIComponent(term)}`, {
      signal: controller.signal,
    });
    if (!response.ok) return { ok: false, status: response.status };
    const data = await response.json();

    // A newer request replaced us while we were awaiting; keep its result.
    if (controller.signal.aborted) return { stale: true };
    return { ok: true, data };
  } catch (error) {
    if (error.name === 'AbortError') {
      // Expected: we or the caller cancelled. Nothing to report to the reader.
      return { ok: false, reason: 'aborted' };
    }
    return { ok: false, reason: 'network', message: error.message };
  }
}

Language: JavaScript
// A timeout that is also cancellable by hand combines two signals.
const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(4000)]);

try {
  const response = await fetch('/api/slow', { signal });
} catch (error) {
  if (error.name === 'TimeoutError') {
    // The timeout fired.
  } else if (error.name === 'AbortError') {
    // controller.abort() fired, or the composed signal aborted for another reason.
  }
}

Explanation

AbortController exposes a signal that fetch observes. Calling abort() rejects the pending promise with a DOMException named AbortError and, in current browsers, ends the underlying connection rather than merely discarding the result. That distinction matters for a search field: cancelling avoids both the wasted server work and the late answer that would otherwise overwrite the newer list.

Ordering is why the controller is created per request and stored in a variable the closure captures. Aborting the previous controller before starting a new one means at most one request is live. Checking signal.aborted after the await handles the remaining race: a request that finished normally but was superseded while you were awaiting it must not render. Reading the captured controller rather than the shared current variable is what makes that check correct, because current may already point at the next request.

AbortError must be separated from genuine failures. If it reaches a generic error branch, a user who typed one more character sees a network-error message for a request they cancelled themselves. The same reasoning applies to timeouts: AbortSignal.timeout() rejects with TimeoutError, not AbortError, so a timeout-specific message is possible without inspecting the error text.

Assumptions

  • AbortSignal.any() and AbortSignal.timeout() require a current browser generation; both are available in Chromium, Firefox, and Safari releases from 2024 onward.
  • AbortSignal.any([signal]) on an aborted member rejects the composed wait with the reason of whichever member aborted first, which is why the two error names are checked separately above.

Common mistakes

  • Creating one controller for the whole component and aborting it more than once, after which every later request inherits an already-aborted signal and fails instantly.
  • Swallowing all errors in the AbortError branch, which also hides a real cancellation of a request the reader never asked to stop.
  • Forgetting to clear current on completion, so a later abort targets a finished request.

Caveats

  • Aborting after the response body has started streaming stops the read but does not undo any server-side side effect; a cancelled POST may already have been applied.
  • An aborted request still counts against the browser’s per-host connection limits until the rejection is delivered, so aborting a hundred parallel requests is not a substitute for limiting concurrency.

Prerequisite

Examples that treat this as a prerequisite

Derived at build time from the editorial graph; no edge is invented here.

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References