jsguides

AbortController Patterns in JavaScript

What is AbortController?

AbortController is a browser and Node.js API that lets you cancel asynchronous operations. It pairs with an AbortSignal to communicate cancellation requests to any async function that knows how to listen for it.

The most common use case is cancelling fetch() requests, but you can wire AbortController patterns into any Promise-based API. Once you understand the signal-sharing pattern, you can abort anything: timeouts, streaming reads, WebSocket connections, or your own custom async functions.

The basic pattern

You create an AbortController, pass its signal to one or more async operations, then call controller.abort() to cancel them.

const controller = new AbortController();
const { signal } = controller;

// Pass the signal to fetch
const response = await fetch('/api/data', { signal });

// Cancel after 3 seconds
setTimeout(() => controller.abort(), 3000);

When abort() is called, the signal fires an 'abort' event. fetch() listens for this and rejects with an AbortError. You don’t need to do anything special inside fetch() — it handles the signal automatically.

Cancelling fetch with a Timeout

A practical use: adding a timeout to a fetch call. Here’s a reusable function that wraps this pattern cleanly.

async function fetchWithTimeout(url, ms = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), ms);

  try {
    const response = await fetch(url, { signal: controller.signal });
    const data = await response.json();
    clearTimeout(timeoutId);
    return data;
  } catch (err) {
    clearTimeout(timeoutId);
    if (err.name === 'AbortError') {
      console.error('Request was cancelled (timeout or manual abort)');
    }
    throw err;
  }
}

A few things to notice here. We clear the timeout in both the success and catch branches — if the fetch succeeds before the timeout fires, we don’t want the timer sitting around. We check err.name === 'AbortError' specifically when catching, because other errors (network failures, 4xx responses) will have different names.

Making your own async functions abortable

fetch() knows how to handle AbortSignal natively, but you can add the same behaviour to any Promise-based function. Pass a signal as an option or parameter, and check it inside the function.

function pollUntilReady({ signal, maxAttempts = 20 }) {
  return new Promise((resolve, reject) => {
    let attempts = 0;

    const id = setInterval(() => {
      if (signal.aborted) {
        clearInterval(id);
        reject(signal.reason);
        return;
      }

      attempts += 1;
      if (attempts >= maxAttempts) {
        clearInterval(id);
        resolve({ status: 'ready', attempts });
      }
    }, 200);

    // Clean up if the signal aborts
    signal.addEventListener('abort', () => {
      clearInterval(id);
      reject(signal.reason);
    }, { once: true });
  });
}

const controller = new AbortController();
pollUntilReady({ signal: controller.signal })
  .then(result => console.log('Result:', result))
  .catch(err => {
    if (err.name === 'AbortError') return; // user cancelled, ignore
    console.error('Error:', err);
  });

// Abort after 5 seconds
setTimeout(() => controller.abort(), 5000);

signal.throwIfAborted() is a synchronous counterpart to the event listener approach. It throws immediately if the signal is already aborted when you call it, which saves you from entering the Promise constructor only to reject right after. Call it at the top of any async function that accepts a signal to fail fast before doing any real work.

function doWork({ signal }) {
  signal.throwIfAborted(); // fail fast if already cancelled

  // ... do actual work
}

Combining multiple signals

Sometimes you want a single operation to abort on any of several conditions, such as pairing a timeout deadline with a manual cancel button. AbortSignal.any() takes an array of signals and returns a new combined signal that aborts as soon as any input signal fires, so both cancellation sources feed into the same operation.

async function fetchWithDeadline(url, deadlineMs) {
  const controller = new AbortController();
  const deadlineSignal = AbortSignal.timeout(deadlineMs);
  const combined = AbortSignal.any([controller.signal, deadlineSignal]);

  const response = await fetch(url, { signal: combined });
  return response.json();
}

Now the fetch will abort if you call controller.abort() manually, or automatically when the deadline expires. The combined signal rejects with either AbortError (user-initiated) or TimeoutError (deadline expired), so you can distinguish between the two in your catch block:

try {
  const data = await fetchWithDeadline('/api/heavy', 3000);
  console.log(data);
} catch (err) {
  if (err.name === 'TimeoutError') {
    console.error('Request exceeded the deadline');
  } else if (err.name === 'AbortError') {
    console.error('Request was cancelled by user');
  } else {
    console.error('Other error:', err.message);
  }
}

AbortSignal.timeout() is cleaner than a manual setTimeout with an abort call, because it produces a TimeoutError with a distinct name instead of a generic AbortError. You can also pass a custom reason to AbortSignal.timeout(ms, reason) in environments that support it.

The abort Event vs throwIfAborted()

There are two ways to respond to an abort signal. The 'abort' event fires asynchronously when the signal is aborted — use this when you’re inside a Promise and need to clean up resources (intervals, streams, etc.).

throwIfAborted() is a synchronous check, it throws immediately if the signal is already aborted. Use this at the start of a function to fail fast rather than starting work that will immediately be cancelled.

function startTask({ signal }) {
  // Fail synchronously if already aborted
  signal.throwIfAborted();

  // ... proceed with the task
}

Common Mistakes

Reusing a spent signal. Once a signal is aborted, it’s aborted forever. Any subsequent operation using that signal rejects immediately. Always create a fresh AbortController for each independent operation.

Missing signal links in a pipeline. If you’re building an abortable chain (fetch, then body parsing, then processing), the signal must reach every step. If you pass it to fetch but not to the body reader, aborting won’t stop the read.

Memory leaks from event listeners. When you attach an abort listener inside a Promise constructor, always use { once: true } or explicitly remove the listener. Without this, the listener persists after the Promise settles, keeping references alive.

// Safe: clean up automatically
signal.addEventListener('abort', () => {
  clearInterval(id);
  reject(signal.reason);
}, { once: true });

Confusing signal.aborted with signal.reason. signal.aborted is a boolean, true if aborted and false otherwise. signal.reason is the actual value passed to abort(), which defaults to an AbortError DOMException. Use reason to pass structured information about why the cancellation happened:

controller.abort({ code: 'TIMEOUT', ms: 5000 });

Propagate signals through layers

A signal is only useful if it reaches every async step that might outlive the user action. Pass it through fetch, parsing, timers, and any helper that can wait. That way cancellation means the whole operation stops, not just the first network call. Consistent propagation also makes code reviews much easier.

Timeouts and user cancellation

A deadline and a manual cancel button are related but not identical. The deadline says the work has taken too long, while the user cancel says they changed their mind. Keeping those reasons separate helps you show the right message and decide whether a retry is sensible. The signal API gives you room for both.

Clean up listeners

If you attach an abort listener inside a promise, make sure it cannot linger after the task settles. Use { once: true } or remove the handler in cleanup code. Small cleanup habits matter a lot in long-lived pages where the same pattern can run many times.

Design abortable APIs up front

It is easier to make a function abortable from day one than to bolt cancellation onto it later. Accept a signal in the options object, document how cancellation behaves, and decide what cleanup should happen when the signal fires. That keeps the API predictable and makes later integration much less awkward.

Pass signals everywhere

When one async step calls another, carry the same signal through the whole path. That gives the caller one place to stop the work and prevents a half-cancelled operation that keeps running in the background. Shared cancellation is easiest when every layer accepts the same option shape.

Distinguish Reasons

A deadline, a user cancel, and a transport failure should not all look the same. Give each one a clear reason so the UI can respond with the right message. That makes retries, alerts, and logging easier to keep separate.

Design for Reuse

Abortable helpers are easier to reuse when they accept a signal in a simple options object and clean up after themselves. That makes them fit nicely into fetch flows, timers, and custom background work without needing different cancellation rules each time. The result is a calmer async API.

Keep cleanup close

The code that starts a task should usually own the code that stops it. That keeps timers, listeners, and streams from hanging around after the work is done. A tight start and stop pair also makes the function easier to read later.

See Also

  • fetch(), the most common AbortSignal consumer
  • Promise, the underlying mechanism for async cancellation
  • Async functions, combining abort with async/await syntax