Fetch: Abort

A fetch returns a promise, and a promise has no cancel button. Once you kick one off, the language gives you no built-in way to say “stop, I changed my mind.” Yet you often need exactly that. A user types in a search box and the previous request is now stale. Someone navigates away before a slow page finishes loading. The request is still traveling over the network, still holding a connection, still going to fire a .then you no longer care about.

The answer is a small helper object called AbortController. It doesn’t cancel promises in general — nothing does — but it gives you a standard way to signal “abort,” and fetch is built to listen for that signal. The same mechanism works for your own async code too, so once you learn it here you can reuse it anywhere.

The AbortController object

You start by creating a controller:

let controller = new AbortController();

There’s almost nothing to it. A controller gives you exactly two things:

  • a method abort() that you call to trigger cancellation, and
  • a property signal that other code watches to find out cancellation happened.

Calling abort() does two observable things at once:

  1. controller.signal fires an "abort" event, and
  2. controller.signal.aborted flips from false to true.

So there are always two sides to the arrangement. One side does the cancelable work and watches signal. The other side decides when to pull the plug and calls abort(). They communicate only through the signal — they don’t need to know anything else about each other.

worker side
signal.addEventListener(‘abort’, …)
canceller side
controller.abort()
abort() →
controller.signal
→ fires ‘abort’, sets aborted = true
One side listens on signal; the other calls abort(). The signal is the only thing they share.

Here’s the whole cycle with no fetch involved yet, just to see the two sides talk:

let controller = new AbortController();
let signal = controller.signal;

// The worker side: it holds the signal and reacts when abort fires.
signal.addEventListener('abort', () => alert("abort!"));

// The canceller side, at any later point:
controller.abort(); // triggers "abort!"

// The event has fired and the flag is now set
alert(signal.aborted); // true

Strip away the naming and AbortController is a purpose-built event emitter: abort() dispatches one event and sets one boolean. You could hand-roll something equivalent with your own callback. The payoff is that this shape is standard, and fetch already speaks it.

Try the two sides yourself. The worker side has already subscribed to the "abort" event; press the button to be the canceller. Notice the event fires exactly once, and the flag stays true afterwards:

interactiveabort() fires the event and flips the flag

Using with fetch

To make a fetch cancelable, hand it the controller’s signal in the options object:

let controller = new AbortController();
fetch(url, {
  signal: controller.signal
});

From here fetch watches that signal for you. It has registered its own "abort" listener internally, so the moment you cancel, it tears down the request.

When you want to stop the request, call:

controller.abort();

fetch receives the event through signal and aborts the underlying network request. There’s one consequence you must handle: the fetch promise doesn’t resolve, it rejects. The rejection is a DOMException whose name is "AbortError". So wrap the await in try..catch and distinguish an intentional abort from a real network failure.

fetch(url, { signal })
request in flight…
controller.abort()
promise rejects
throw DOMExceptionname === ‘AbortError’
Timeline of an aborted fetch: the request is in flight when abort() lands, and the promise rejects with AbortError.

A complete example that gives the server one second, then gives up:

// abort in 1 second
let controller = new AbortController();
setTimeout(() => controller.abort(), 1000);

try {
  let response = await fetch('/api/reports/heavy-export', {
    signal: controller.signal
  });
} catch(err) {
  if (err.name == 'AbortError') { // handle abort()
    alert("Aborted!");
  } else {
    throw err; // some other error, re-throw
  }
}

The preview here has no network, so this demo stands in a slow loader for fetch: a promise that resolves after two seconds, but rejects with a real DOMException named "AbortError" if its signal fires first. Start the load, then hit cancel before it finishes and watch the catch distinguish the abort from a genuine failure:

interactiveCancel a slow task and catch AbortError

The debounced search box is the classic use. On each keystroke you abort the previous request before starting a new one, so a slow early response can’t arrive late and overwrite fresher results:

let controller;

async function search(query) {
  // cancel the previous request, if any is still running
  if (controller) controller.abort();

  controller = new AbortController();
  try {
    let response = await fetch(`/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal
    });
    return await response.json();
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
    // aborted by a newer keystroke — ignore
  }
}

AbortController is scalable

One controller can drive many operations. Every consumer you give the same signal to gets canceled together when you call abort() once. That makes it easy to fan out several requests and treat them as a single unit.

Here’s a sketch that fires many URLs in parallel, all wired to one controller:

let urls = [...]; // a list of urls to fetch in parallel

let controller = new AbortController();

// an array of fetch promises, all sharing the same signal
let fetchJobs = urls.map(url => fetch(url, {
  signal: controller.signal
}));

let results = await Promise.all(fetchJobs);

// a single controller.abort() call from anywhere
// aborts every fetch in the list at once
controller.signal
abort() ↓↓↓
fetch #1
fetch #2
fetch #3
ourJob
One signal, many listeners: a single abort() fans out to every operation wired to it.

Here that fan-out is live. Three independent tasks share one signal; each finishes on its own timer, but a single abort() pulls the plug on every task still running:

interactiveOne signal, many tasks, one abort()

The reach isn’t limited to fetch. If you have your own asynchronous work — a timer, a WebSocket read, a computation you poll — you can hook it into the same controller. Just listen for the abort event inside your task and stop when it fires:

let urls = [...];
let controller = new AbortController();

let ourJob = new Promise((resolve, reject) => { // our custom task
  ...
  controller.signal.addEventListener('abort', reject);
});

let fetchJobs = urls.map(url => fetch(url, { // the fetches
  signal: controller.signal
}));

// wait for the fetches and our task together
let results = await Promise.all([...fetchJobs, ourJob]);

// controller.abort() from anywhere now stops
// every fetch AND ourJob in one shot

Summary

  • AbortController is a tiny object with one method, abort(), and one property, signal. Calling abort() fires an "abort" event on the signal and sets signal.aborted to true.
  • fetch integrates with it directly. Pass signal in the options, and a later controller.abort() cancels the request. The fetch promise then rejects with an AbortError, which you handle in try..catch while re-throwing anything that isn’t an abort.
  • The pattern scales: share one signal across many fetches (and your own tasks) to cancel them all with a single call.
  • The whole “call abort()” → “react to the abort event” contract is standard and general. You can lean on it far beyond fetch.