EdgeCases Logo
Mar 2026
Browser APIs
Deep
7 min read

AbortSignal.any(): Composing Cancellation Signals

Combine timeout, user cancellation, and navigation signals into one — without race conditions or manual cleanup.

javascript
abort-signal
async
cancellation
fetch
patterns

A single AbortController handles one cancellation reason. Real applications need more: "Cancel if the user clicks away, OR if 30 seconds pass, OR if we navigate to another page." Before AbortSignal.any(), composing these meant manual event listeners and cleanup bugs. Now there's a clean primitive.

The Problem: Multiple Cancellation Sources

// ❌ Naive approach: separate signals don't compose
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(30_000);

// Which signal do we pass to fetch? We need BOTH.
fetch(url, { signal: userController.signal }); // Ignores timeout
fetch(url, { signal: timeoutSignal }); // Ignores user cancel

// ❌ Manual composition: verbose and error-prone
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
button.onclick = () => controller.abort();

// Don't forget to clean up!
try {
  await fetch(url, { signal: controller.signal });
} finally {
  clearTimeout(timeout);
  button.onclick = null; // Memory leak if forgotten
}

AbortSignal.any(): The Solution

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

// ✅ Combine any number of signals
const combined = AbortSignal.any([
  userController.signal,
  timeoutSignal,
]);

// Aborts when EITHER signal fires
const response = await fetch(url, { signal: combined });

AbortSignal.any() returns a new signal that aborts when any of its input signals abort. The combined signal's reason is set to the reason of the first signal that fired.

Common Patterns

Timeout + User Cancellation

async function fetchWithCancel(url, cancelButton) {
  const userController = new AbortController();
  
  // User can click to cancel
  cancelButton.onclick = () => userController.abort('User cancelled');
  
  try {
    return await fetch(url, {
      signal: AbortSignal.any([
        userController.signal,
        AbortSignal.timeout(10_000),
      ]),
    });
  } finally {
    // Cleanup still needed for the event listener
    cancelButton.onclick = null;
  }
}

Navigation + Request Cancellation

// React pattern: cancel requests on unmount
function useFetch(url) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    const controller = new AbortController();
    
    fetch(url, {
      signal: AbortSignal.any([
        controller.signal,
        AbortSignal.timeout(30_000),
      ]),
    })
      .then(res => res.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') throw err;
      });
    
    return () => controller.abort('Component unmounted');
  }, [url]);
  
  return data;
}

Multiple Concurrent Operations

// Cancel all pending requests with one controller
async function fetchDashboard(controller) {
  const signal = AbortSignal.any([
    controller.signal,
    AbortSignal.timeout(60_000),
  ]);
  
  // All requests share the combined signal
  const [users, posts, analytics] = await Promise.all([
    fetch('/api/users', { signal }),
    fetch('/api/posts', { signal }),
    fetch('/api/analytics', { signal }),
  ]);
  
  return { users, posts, analytics };
}

// Caller can cancel the whole dashboard load
const dashboardController = new AbortController();
cancelAllButton.onclick = () => dashboardController.abort();
await fetchDashboard(dashboardController);

Identifying the Abort Reason

When a combined signal aborts, you often need to know why:

const controller = new AbortController();
const combined = AbortSignal.any([
  controller.signal,
  AbortSignal.timeout(5000),
]);

try {
  await fetch(url, { signal: combined });
} catch (err) {
  if (err.name === 'TimeoutError') {
    // AbortSignal.timeout() throws TimeoutError
    console.log('Request timed out');
  } else if (err.name === 'AbortError') {
    // Manual abort
    console.log('Aborted:', err.message);
    // Check reason if you set a custom one
    if (combined.reason === 'User cancelled') {
      showToast('Download cancelled');
    }
  }
}

Note: AbortSignal.timeout() uses TimeoutError, not AbortError. This makes it easy to distinguish timeout vs manual cancellation.

Edge Cases and Gotchas

Already-Aborted Signals

const aborted = AbortSignal.abort('Already done');

// Combined signal is immediately aborted
const combined = AbortSignal.any([
  aborted,
  AbortSignal.timeout(10_000),
]);

combined.aborted; // true
combined.reason;  // 'Already done'

If any input signal is already aborted, the combined signal is immediately aborted with that signal's reason.

Memory: Signal Subscriptions

// Each call creates event subscriptions
function makeSignal() {
  return AbortSignal.any([
    AbortSignal.timeout(60_000),
    someGlobalController.signal,
  ]);
}

// ⚠️ Creating many combined signals subscribes many listeners
// to someGlobalController.signal
for (let i = 0; i < 1000; i++) {
  fetch(urls[i], { signal: makeSignal() });
}

The combined signal attaches listeners to all input signals. If inputs are long-lived (like a global controller), creating many combined signals can accumulate listeners. In practice, this is rarely an issue because listeners are cleaned up when the combined signal is garbage collected.

Order Matters for Reason

const a = new AbortController();
const b = new AbortController();

// Abort both synchronously
a.abort('Reason A');
b.abort('Reason B');

const combined = AbortSignal.any([a.signal, b.signal]);
combined.reason; // 'Reason A' (first in array)

When multiple signals are already aborted, the reason comes from the first one in the array.

Browser Support

AbortSignal.any() shipped in Chrome 116, Safari 17.4, and Firefox 124. For older browsers, use a polyfill or manual composition. There's no @supports for JS methods — feature-detect with:

if (typeof AbortSignal.any === 'function') {
  // Use AbortSignal.any()
} else {
  // Fallback to manual composition
}

Advertisement

Related Insights

Explore related edge cases and patterns

TypeScript
Expert
TypeScript infer: Advanced Extraction Patterns
8 min
Browser APIs
Surface
Web Workers Structured Clone: What Can't Cross the Boundary
6 min
JavaScript
Deep
structuredClone(): The Deep Copy That Isn't Always Deep
7 min

Advertisement