EdgeCases Logo
Feb 2026
Browser APIs
Surface
5 min read

AbortController: Patterns Beyond Fetch

AbortController isn't just for fetch—use it to cancel event listeners, timeouts, and any async operation with a single abort() call.

javascript
abortcontroller
event-listeners
cleanup
react
async

Most developers know AbortController for cancelling fetch requests. But it's a general-purpose cancellation primitive—perfect for event listeners, timeouts, and any async operation. One abort() cleans up everything sharing that signal.

The Classic: Fetch Cancellation

const controller = new AbortController();

fetch('/api/data', { signal: controller.signal })
  .then(res => res.json())
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Request cancelled');
    }
  });

// Later: cancel the request
controller.abort();

This is well-known. But AbortController does much more.

Event Listener Cleanup

addEventListener accepts a signal option. When aborted, the listener is automatically removed—no need to keep a reference to the handler:

const controller = new AbortController();

// Add multiple listeners with one signal
window.addEventListener('resize', handleResize, { signal: controller.signal });
window.addEventListener('scroll', handleScroll, { signal: controller.signal });
document.addEventListener('keydown', handleKey, { signal: controller.signal });

// One abort() removes all three
controller.abort();

No more storing handler references just to call removeEventListener. This is especially clean in React's useEffect:

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

  window.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') onClose();
  }, { signal });

  window.addEventListener('resize', recalculate, { signal });

  return () => controller.abort();
}, [onClose]);

The cleanup function is a single line. The inline handler works because you don't need to pass the same function reference to removeEventListener.

Timeout Cancellation

Link setTimeout and setInterval to an abort signal:

function cancellableTimeout(ms, signal) {
  return new Promise((resolve, reject) => {
    const id = setTimeout(resolve, ms);
    
    signal?.addEventListener('abort', () => {
      clearTimeout(id);
      reject(new DOMException('Timeout aborted', 'AbortError'));
    });
  });
}

const controller = new AbortController();

cancellableTimeout(5000, controller.signal)
  .then(() => console.log('Timer completed'))
  .catch(err => {
    if (err.name === 'AbortError') console.log('Timer cancelled');
  });

// Cancel before 5s
controller.abort();

Composing Multiple Operations

Use one controller to coordinate multiple async operations:

async function loadDashboard(signal) {
  const [users, posts, analytics] = await Promise.all([
    fetch('/api/users', { signal }),
    fetch('/api/posts', { signal }),
    fetch('/api/analytics', { signal }),
  ]);
  
  // If any request fails due to abort, all are cancelled
  return { users, posts, analytics };
}

const controller = new AbortController();

loadDashboard(controller.signal);

// User navigates away—cancel everything
controller.abort();

AbortSignal.timeout()

Create a signal that auto-aborts after a duration—built into the platform:

// Abort after 5 seconds
const signal = AbortSignal.timeout(5000);

fetch('/api/slow-endpoint', { signal })
  .catch(err => {
    if (err.name === 'TimeoutError') {
      console.log('Request timed out');
    }
  });

Note: timeout signals throw TimeoutError, not AbortError.

AbortSignal.any()

Combine multiple signals—abort when any of them fires:

const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(10000);

const combinedSignal = AbortSignal.any([
  userController.signal,
  timeoutSignal,
]);

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

// Cancels if: user aborts OR 10 seconds pass

React Pattern: Race Condition Prevention

A common bug: rapid state changes cause stale responses to overwrite fresh ones. AbortController fixes this elegantly:

function useSearch(query) {
  const [results, setResults] = useState([]);
  const controllerRef = useRef(null);

  useEffect(() => {
    // Cancel previous request
    controllerRef.current?.abort();
    controllerRef.current = new AbortController();

    fetch(`/api/search?q=${query}`, {
      signal: controllerRef.current.signal,
    })
      .then(res => res.json())
      .then(setResults)
      .catch(err => {
        if (err.name !== 'AbortError') throw err;
      });

    return () => controllerRef.current?.abort();
  }, [query]);

  return results;
}

Each query change aborts the previous request. No race conditions, no stale data.

Custom Async APIs

Make any async API cancellable by accepting a signal:

async function processLargeDataset(data, signal) {
  const results = [];
  
  for (const item of data) {
    // Check for abort between iterations
    if (signal?.aborted) {
      throw new DOMException('Processing cancelled', 'AbortError');
    }
    
    results.push(await processItem(item));
  }
  
  return results;
}

const controller = new AbortController();
processLargeDataset(bigArray, controller.signal);

Key Takeaways

  • addEventListener accepts a signal option—one abort() removes all listeners
  • Use AbortSignal.timeout() for auto-cancelling after a duration
  • Use AbortSignal.any() to combine multiple abort conditions
  • In React useEffect, abort in the cleanup function for clean resource management
  • Pass signals through your async APIs to make them cancellable

Advertisement

Related Insights

Explore related edge cases and patterns

TypeScript
Surface
TypeScript's satisfies Operator: Validate Without Widening
6 min
CSS
Deep
OpenType Features: Ligatures, Tabular Numbers, and Small Caps
7 min

Advertisement