AbortController Patterns in JavaScript

· 5 min read · Updated March 23, 2026 · intermediate
abortcontroller async fetch javascript promises

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 it 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 useful here too — it throws immediately if the signal is already aborted when you call it, which saves you from starting work that will just be cancelled right away.

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. AbortSignal.any() takes an array of signals and returns a new combined signal that aborts as soon as any input signal aborts.

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, 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 });

See Also

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