AbortController Patterns: Cancel Fetch, Timeouts, and Event Listeners
Master AbortController in JavaScript -- cancel fetch requests, debounce events, set timeouts, and build cancellable async workflows.
What you'll learn
- ✓How AbortController and AbortSignal work together
- ✓Patterns for cancelling fetch requests and setting timeouts
- ✓Advanced patterns for composing signals and cleaning up listeners
Prerequisites
- •JavaScript Promises and async/await
- •Familiarity with the Fetch API
AbortController is a built-in API for cancelling asynchronous operations. It was originally designed for cancelling fetch requests, but its uses extend far beyond that — event listeners, timeouts, streams, and any custom async workflow can be made cancellable.
The basics
An AbortController has two parts:
- controller — the object you call
.abort()on - signal — the
AbortSignalyou pass to cancellable operations
const controller = new AbortController();
const signal = controller.signal;
console.log(signal.aborted); // false
controller.abort();
console.log(signal.aborted); // true
Cancelling fetch requests
The most common use case. Pass the signal to fetch() and call abort() to cancel.
const controller = new AbortController();
fetch("https://api.example.com/data", { signal: controller.signal })
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => {
if (error.name === "AbortError") {
console.log("Fetch was cancelled");
} else {
console.error("Fetch failed:", error);
}
});
// Cancel the request after 100ms
setTimeout(() => controller.abort(), 100);
When aborted, fetch rejects with an AbortError. Always check error.name to distinguish cancellation from actual failures.
Pattern 1: Fetch with timeout
Use AbortSignal.timeout() for a built-in timeout mechanism, or build your own for older environments.
// Modern approach -- AbortSignal.timeout()
async function fetchWithTimeout(url, timeoutMs = 5000) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
});
return await response.json();
} catch (err) {
if (err.name === "TimeoutError") {
console.error(`Request to ${url} timed out after ${timeoutMs}ms`);
}
throw err;
}
}
// Manual approach (for older environments)
async function fetchWithTimeoutManual(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
Pattern 2: Cancel previous requests (search autocomplete)
When building search autocomplete, cancel the previous request whenever the user types a new character.
let currentController = null;
async function search(query) {
// Cancel any in-flight request
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: currentController.signal,
});
const results = await response.json();
displayResults(results);
} catch (error) {
if (error.name === "AbortError") {
// Silently ignore -- a newer request replaced this one
return;
}
console.error("Search failed:", error);
}
}
// Wire up to input
document.querySelector("#search").addEventListener("input", (e) => {
search(e.target.value);
});
Pattern 3: Removing event listeners
AbortSignal works with addEventListener, making cleanup much simpler than manually tracking and removing listeners.
const controller = new AbortController();
document.addEventListener(
"click",
(e) => console.log("clicked", e.target),
{ signal: controller.signal }
);
document.addEventListener(
"keydown",
(e) => console.log("key", e.key),
{ signal: controller.signal }
);
window.addEventListener(
"resize",
() => console.log("resized"),
{ signal: controller.signal }
);
// Later: remove ALL three listeners at once
controller.abort();
This is especially powerful in classes or modules that set up many listeners.
class ModalDialog {
#controller = new AbortController();
open() {
const { signal } = this.#controller;
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") this.close();
}, { signal });
this.overlay.addEventListener("click", () => {
this.close();
}, { signal });
window.addEventListener("popstate", () => {
this.close();
}, { signal });
}
close() {
this.#controller.abort(); // Removes all listeners at once
this.element.remove();
}
}
Pattern 4: Composing multiple signals
AbortSignal.any() creates a signal that aborts when any of its source signals abort. This is useful for combining user cancellation with timeouts.
async function fetchWithUserCancel(url, userSignal) {
const timeoutSignal = AbortSignal.timeout(10000);
const combinedSignal = AbortSignal.any([userSignal, timeoutSignal]);
try {
const response = await fetch(url, { signal: combinedSignal });
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
if (userSignal.aborted) {
console.log("User cancelled the request");
} else {
console.log("Request timed out");
}
}
throw error;
}
}
// Usage
const userController = new AbortController();
fetchWithUserCancel("/api/data", userController.signal);
// User clicks "Cancel" button
document.querySelector("#cancel").addEventListener("click", () => {
userController.abort();
});
Pattern 5: Making custom async functions cancellable
You can check signal.aborted or listen for the abort event in your own async code.
function delay(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
return reject(new DOMException("Delay cancelled", "AbortError"));
}
const timeoutId = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timeoutId);
reject(new DOMException("Delay cancelled", "AbortError"));
});
});
}
// Usage
const controller = new AbortController();
delay(5000, controller.signal)
.then(() => console.log("Done waiting"))
.catch((err) => console.log(err.message));
// Cancel after 1 second
setTimeout(() => controller.abort(), 1000);
// "Delay cancelled"
Pattern 6: React useEffect cleanup
AbortController is the standard pattern for cleaning up async effects in React.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error("Failed to load user:", error);
}
}
}
loadUser();
// Cleanup: abort when userId changes or component unmounts
return () => controller.abort();
}, [userId]);
return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
Pattern 7: Polling with cancellation
async function pollForStatus(url, intervalMs = 2000, signal) {
while (!signal?.aborted) {
try {
const response = await fetch(url, { signal });
const data = await response.json();
if (data.status === "complete") {
return data;
}
await delay(intervalMs, signal);
} catch (err) {
if (err.name === "AbortError") return null;
throw err;
}
}
return null;
}
// Start polling
const controller = new AbortController();
const result = pollForStatus("/api/job/123/status", 3000, controller.signal);
// Stop polling after 30 seconds
setTimeout(() => controller.abort(), 30000);
Abort reason
You can pass a reason to abort() to provide context about why the operation was cancelled.
const controller = new AbortController();
controller.signal.addEventListener("abort", () => {
console.log("Reason:", controller.signal.reason);
});
controller.abort("User navigated away");
// "Reason: User navigated away"
// Or with a custom error
controller.abort(new Error("Session expired"));
Summary
AbortController is the standard cancellation mechanism in JavaScript:
- Pass
signaltofetch()to cancel HTTP requests. - Use
AbortSignal.timeout()for built-in request timeouts. - Cancel previous requests in autocomplete and search patterns.
- Pass
signaltoaddEventListenerfor bulk listener cleanup. - Use
AbortSignal.any()to combine multiple cancellation sources. - Make your own async APIs abortable by accepting a
signaloption. - In React, use AbortController in
useEffectcleanup to prevent state updates on unmounted components.
Every async operation in your application should be cancellable. AbortController gives you a single, consistent pattern to achieve this without custom cancellation logic.
Related articles
- JavaScript The Streams API in JavaScript
Process data incrementally with the JavaScript Streams API -- ReadableStream, WritableStream, TransformStream, and practical patterns for large data handling.
- JavaScript JavaScript Event Loop Explained: How Async Code Really Works
Understand how the JavaScript event loop handles async operations including the call stack, microtasks, macrotasks, and execution order with practical examples.
- JavaScript JavaScript Streams API: Process Data Incrementally
Master the JavaScript Streams API to process large files, network responses, and data pipelines incrementally without loading everything into memory.
- JavaScript JavaScript Error Handling Patterns and Best Practices
Master JavaScript error handling with custom error classes, async error patterns, Result types, error boundaries, and strategies for robust production code.