Skip to content

AbortController and Cancellation

intermediate14 min read

The Memory Leak Nobody Noticed

You've probably written code like this. A React component fetches user data on mount:

useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data));
}, [userId]);

The user navigates away before the fetch completes. The component unmounts. The fetch finishes, calls setUser on an unmounted component. React warns: "Can't perform a state update on an unmounted component." More importantly, the fetch response sits in memory, the .then closure keeps the component scope alive, and in a single-page app with rapid navigation, hundreds of abandoned fetches pile up.

The fix is cancellation. And for a long time, JavaScript just... didn't have a standard way to do it. Then AbortController arrived, and it changed everything. It's now the foundation of every robust async pattern.

The Mental Model

Mental Model

AbortController is a kill switch. You create the switch (controller), hand the wire (signal) to an operation, and at any point you can flip the switch (abort). The operation checks the wire and stops immediately. Multiple operations can share the same wire — flipping the switch once cancels them all.

The API

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

// Pass signal to an operation
fetch('/api/data', { signal });

// Cancel the operation
controller.abort();

// Check if aborted
signal.aborted; // true

// Listen for abort
signal.addEventListener('abort', () => {
  console.log('Operation was cancelled');
  console.log(signal.reason); // the abort reason
});

Three parts:

  1. AbortController — the thing with the abort() method
  2. AbortSignal — the read-only signal passed to operations
  3. abort event — fires on the signal when abort() is called

Cancelling fetch

const controller = new AbortController();

const fetchPromise = fetch('/api/slow', {
  signal: controller.signal,
});

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

try {
  const response = await fetchPromise;
  const data = await response.json();
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('Fetch was cancelled');
  } else {
    throw err; // re-throw non-cancellation errors
  }
}

When abort() is called, fetch rejects with an AbortError. The actual HTTP request is cancelled at the network level — the browser drops the TCP connection, saving bandwidth and server resources.

Quiz
What happens when you call abort() on an AbortController whose signal is passed to a fetch?

AbortSignal.timeout — Built-in Timeout

Even better -- instead of wiring up setTimeout + abort() yourself, use the static AbortSignal.timeout():

// Clean timeout pattern
try {
  const response = await fetch('/api/data', {
    signal: AbortSignal.timeout(5000), // 5 second timeout
  });
  const data = await response.json();
} catch (err) {
  if (err.name === 'TimeoutError') {
    console.log('Request timed out');
  } else if (err.name === 'AbortError') {
    console.log('Request was aborted');
  }
}

AbortSignal.timeout() throws a TimeoutError (not AbortError), making it easy to distinguish timeouts from manual cancellation.

Combining Signals: AbortSignal.any

But what if you need both a timeout AND manual cancellation? This is where it gets really elegant:

const manualController = new AbortController();

const combinedSignal = AbortSignal.any([
  manualController.signal,       // manual cancel
  AbortSignal.timeout(5000),     // 5s timeout
]);

fetch('/api/data', { signal: combinedSignal });

// Either of these triggers cancellation:
// - manualController.abort()
// - 5 seconds elapsing

AbortSignal.any() creates a signal that aborts when ANY of its source signals abort. This composes cleanly — you can combine timeout, user cancellation, and component unmounting signals.

Building Cancellable Async Operations

Here's where AbortController really shines -- it isn't just for fetch. You can make any async operation cancellable:

function delay(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    // Check if already aborted
    if (signal?.aborted) {
      reject(signal.reason);
      return;
    }

    const timeoutId = setTimeout(resolve, ms);

    // Clean up on abort
    signal?.addEventListener('abort', () => {
      clearTimeout(timeoutId);
      reject(signal.reason);
    }, { once: true });
  });
}

// Usage
const controller = new AbortController();
try {
  await delay(5000, { signal: controller.signal });
} catch (err) {
  if (err.name === 'AbortError') console.log('Delay cancelled');
}

Pattern: Cancellable Async Iterator

async function* pollAPI(url, interval, signal) {
  while (!signal.aborted) {
    try {
      const res = await fetch(url, { signal });
      yield await res.json();
      await delay(interval, { signal });
    } catch (err) {
      if (err.name === 'AbortError') return; // clean exit
      throw err;
    }
  }
}

// Usage
const controller = new AbortController();
for await (const data of pollAPI('/api/status', 2000, controller.signal)) {
  console.log(data);
  if (data.complete) controller.abort(); // stop polling
}
Quiz
When building a cancellable async function, what should you check BEFORE starting the async work?

Production Scenario: React Data Fetching with Cancellation

Let's put it all together. Here's the correct pattern for fetching in React with cleanup:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    async function loadUser() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        setUser(data);
      } catch (err) {
        if (err.name === 'AbortError') return; // component unmounted — do nothing
        setError(err);
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }

    loadUser();

    return () => controller.abort(); // cleanup on unmount or userId change
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <Profile user={user} />;
}

Key points:

  • controller.abort() in the cleanup function cancels the fetch when the component unmounts or userId changes
  • AbortError is silently ignored — it's expected behavior, not an error
  • State updates are guarded by !controller.signal.aborted to prevent updates after abort

Race Condition: Rapid userId Changes

This is the bug that makes you question your career. Without cancellation, if userId changes from 1 to 2 to 3 quickly:

  • Fetch for user 1 starts
  • Fetch for user 2 starts (user 1 still in flight)
  • Fetch for user 3 starts (user 1 and 2 still in flight)
  • User 2's response arrives first → setUser(user2) — page shows user 2
  • User 3's response arrives → setUser(user3) — page updates to user 3
  • User 1's response arrives last (it was slowest) → setUser(user1) — page reverts to user 1!

With AbortController, changing userId aborts the previous fetch. Only the latest fetch's response is processed.

Using AbortController with EventTarget addEventListener

addEventListener also accepts a signal for automatic cleanup:

const controller = new AbortController();

element.addEventListener('click', handleClick, { signal: controller.signal });
element.addEventListener('keydown', handleKey, { signal: controller.signal });
window.addEventListener('resize', handleResize, { signal: controller.signal });

// Remove all three listeners at once:
controller.abort();

This is much cleaner than tracking and removing each listener individually. Especially useful in React effects:

useEffect(() => {
  const controller = new AbortController();
  window.addEventListener('scroll', onScroll, { signal: controller.signal });
  window.addEventListener('resize', onResize, { signal: controller.signal });
  return () => controller.abort(); // removes both listeners
}, []);

Advanced: Cancellation Token Pattern

For complex operations with multiple cancellation points, you can sprinkle checkpoints throughout your code:

async function complexOperation(signal) {
  // Step 1: Fetch data
  signal.throwIfAborted(); // throws if already aborted
  const res = await fetch('/api/step1', { signal });
  const data1 = await res.json();

  // Step 2: Process (CPU-bound, check between chunks)
  const results = [];
  for (let i = 0; i < data1.items.length; i++) {
    if (i % 100 === 0) signal.throwIfAborted(); // periodic check
    results.push(processItem(data1.items[i]));
  }

  // Step 3: Upload results
  signal.throwIfAborted();
  await fetch('/api/results', {
    method: 'POST',
    body: JSON.stringify(results),
    signal,
  });

  return results;
}

signal.throwIfAborted() throws signal.reason (default: DOMException with name 'AbortError') if the signal has been aborted. Use it as a checkpoint in long operations.

Common Mistakes

What developers doWhat they should do
Reusing an AbortController after calling abort()
signal.aborted is permanently true after abort(). Any new operation given the same signal will be cancelled immediately.
Create a new AbortController for each operation. Once aborted, a controller/signal cannot be reset.
Forgetting to check signal.aborted at the start of async functions
The signal may already be aborted by the time your function runs. Without this check, you'll start work that will fail immediately when it tries to use the signal.
Always check signal.aborted (or call signal.throwIfAborted()) before starting work.
Treating AbortError as a real error in error handling
Cancellation is intentional. Logging AbortErrors, showing them to users, or sending them to error tracking pollutes your monitoring and confuses users.
Filter out AbortError in catch blocks — it's expected behavior, not an error to report.
Only using AbortController with fetch, not with custom async operations or event listeners
AbortController is a general-purpose cancellation primitive. addEventListener accepts signal for automatic cleanup. Custom async operations can check signal.aborted.
Use AbortController for any cancellable operation: custom promises, event listeners, intervals, WebSocket connections.

Challenge: Build a Cancellable Debounce

Challenge: Debounce with AbortController

// Build a debounced search that:
// 1. Waits 300ms after the last keystroke
// 2. Cancels any in-flight fetch when a new keystroke arrives
// 3. Returns results only for the latest query
//
// skeleton:
function createDebouncedSearch(searchFn, delay) {
  // your implementation
  return function search(query) {
    // ...
  };
}
Show Answer
    function createDebouncedSearch(searchFn, delay) {
      let timeoutId = null;
      let abortController = null;

      return function search(query) {
        // Cancel previous timeout
        if (timeoutId) clearTimeout(timeoutId);

        // Cancel previous in-flight request
        if (abortController) abortController.abort();

        return new Promise((resolve, reject) => {
          timeoutId = setTimeout(async () => {
            abortController = new AbortController();
            try {
              const result = await searchFn(query, abortController.signal);
              resolve(result);
            } catch (err) {
              if (err.name === 'AbortError') return; // silently ignore
              reject(err);
            }
          }, delay);
        });
      };
    }

    // Usage:
    const debouncedSearch = createDebouncedSearch(
      (query, signal) => fetch(`/api/search?q=${query}`, { signal }).then(r => r.json()),
      300
    );

    input.addEventListener('input', async (e) => {
      const results = await debouncedSearch(e.target.value);
      renderResults(results);
    });

How it works:

  1. Each keystroke clears the previous timeout (debounce)
  2. Each keystroke aborts the previous fetch (cancellation)
  3. A new AbortController is created for each actual fetch
  4. AbortError is caught and silently ignored
  5. Only the latest query's results are returned

Key Rules

Key Rules
  1. 1Create a NEW AbortController for each operation. Controllers are one-use — once aborted, they can't be reset.
  2. 2Always check signal.aborted (or call throwIfAborted()) at the start of async functions. The signal may already be aborted before your code runs.
  3. 3Use AbortSignal.timeout(ms) for timeouts instead of manual setTimeout + abort. It throws TimeoutError, making it distinguishable from manual AbortError.
  4. 4Use AbortSignal.any([...signals]) to combine timeout, manual cancellation, and lifecycle signals into one.
  5. 5In React effects, create an AbortController and call abort() in the cleanup return. This prevents state updates on unmounted components and cancels stale requests.