AbortSignal.any(): Composing Cancellation Signals
Combine timeout, user cancellation, and navigation signals into one — without race conditions or manual cleanup.
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
Explore these curated resources to deepen your understanding
Official Documentation
Related Insights
Explore related edge cases and patterns
Advertisement