AbortController

Canceling Fetch Requests and Async Operations in JavaScript

Photo by Markus Winkler on Unsplash

As web applications grow more dynamic, they tend to make many network requests fetching data on search input changes, loading paginated content, or polling APIs. But what happens when a user types a new search query before the previous one finishes? Or navigates away from a page while a large download is in progress? Without cancellation, those orphaned requests continue to consume bandwidth, eat up memory, and can even lead to race conditions where stale data overwrites fresh data.

JavaScript’s AbortController is a built-in Web API that solves this problem elegantly. Introduced as part of the DOM specification and supported in all modern browsers, AbortController provides a standardized mechanism to cancel fetch requests, event listeners, and any other async operation that accepts an abort signal.

In this article, you’ll learn how AbortController works under the hood, see practical examples of canceling fetch requests, discover how to use it with event listeners and custom async functions, and understand patterns that will make your applications faster and more reliable.

How AbortController Works

The AbortController API has two key parts: the controller itself and the signal it produces. Think of it like a remote control (the controller) and a receiver (the signal). When you press the “abort” button on the controller, the signal broadcasts that cancellation to anything listening.

Here’s the basic anatomy:

const controller = new AbortController();
const signal = controller.signal;
// Later, when you want to cancel:
controller.abort();

When controller.abort() is called, three things happen simultaneously. First, signal.aborted becomes true. Second, the abort event fires on the signal object. Third, any fetch request using this signal is immediately rejected with an AbortError.

You can verify the signal’s state at any time:

const controller = new AbortController();

console.log(controller.signal.aborted); // false
controller.abort();
console.log(controller.signal.aborted); // true

The first console.log prints false because the controller hasn’t been aborted yet. After calling abort(), the second console.log prints true, confirming the signal has been triggered.

Canceling a Fetch Request

The most common use case for AbortController is canceling a fetch request. You pass the signal to fetch via its options object, and if the controller is aborted before the response arrives, the fetch promise rejects.

const controller = new AbortController();
fetch('https://jsonplaceholder.typicode.com/posts', {
signal: controller.signal
})
.then(response => response.json())
.then(data => console.log('Data received:', data.length, 'posts'))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch was canceled by the user.');
} else {
console.error('Fetch failed:', err);
}
});
// Cancel the request after 50ms
setTimeout(() => controller.abort(), 50);

If the network request takes longer than 50 milliseconds (which is likely), the setTimeout fires controller.abort(), which causes the fetch promise to reject with an AbortError. The catch block detects this by checking err.name === ‘AbortError’ and prints “Fetch was canceled by the user.” instead of treating it as a real error. If the response had arrived in under 50ms, you would see the data logged normally.

The key pattern here is distinguishing abort errors from real network errors. You should always check err.name === ‘AbortError’ to handle cancellations gracefully, since they’re intentional — not failures.

Practical Use Case: Auto-Complete Search

One of the most impactful places to use AbortController is in search-as-you-type features. Without cancellation, typing “JavaScript” could fire 10 separate requests (one per keystroke), and there’s no guarantee the responses arrive in order. The result for “Java” might arrive after “JavaScript”, overwriting the correct results with stale data.

Here’s how to solve this:

let currentController = null;

async function searchPosts(query) {
// Cancel the previous request if it's still in progress
if (currentController) {
currentController.abort();
}
// Create a new controller for this request
currentController = new AbortController();
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?title_like=${query}`,
{ signal: currentController.signal }
);
const results = await response.json();
console.log(`Results for "${query}":`, results.length, 'posts found');
} catch (err) {
if (err.name === 'AbortError') {
console.log(`Search for "${query}" was canceled.`);
} else {
console.error('Search error:', err);
}
}
}
// Simulate rapid typing
searchPosts('s');
searchPosts('su');
searchPosts('sun');

When searchPosts(‘s’) is called, it creates a controller and fires a fetch request. When searchPosts(‘su’) is called immediately after, it aborts the controller from the first call and creates a new one. The same thing happens when searchPosts(‘sun’) is called. The console output will show something like:

Search for "s" was canceled.
Search for "su" was canceled.
Results for "sun": 4 posts found

Only the final request completes successfully. The previous two are canceled, preventing race conditions and wasted bandwidth. This is the same pattern that libraries like React Query and SWR use internally.

Setting a Request Timeout with AbortSignal.timeout()

A common need is to cancel a request if it takes too long. Modern browsers support AbortSignal.timeout(), a static method that creates a signal which auto-aborts after a specified number of milliseconds.

async function fetchWithTimeout() {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
signal: AbortSignal.timeout(3000) // 3-second timeout
});
const data = await response.json();
console.log('Fetched', data.length, 'posts within time limit');
} catch (err) {
if (err.name === 'TimeoutError') {
console.error('Request timed out after 3 seconds.');
} else if (err.name === 'AbortError') {
console.error('Request was aborted.');
} else {
console.error('Network error:', err);
}
}
}

fetchWithTimeout();

If the API responds within 3 seconds, you’ll see “Fetched 100 posts within time limit”. If the API takes longer than 3 seconds, the signal automatically aborts and the catch block logs “Request timed out after 3 seconds.”. Notice that AbortSignal.timeout() throws a TimeoutError (not an AbortError), which allows you to differentiate between manual cancellation and timeouts.

Combining Multiple Signals with AbortSignal.any()

Sometimes you want to cancel a request if either a timeout expires or the user manually cancels. AbortSignal.any() accepts an array of signals and aborts when any one of them fires.

const manualController = new AbortController();

const combinedSignal = AbortSignal.any([
manualController.signal,
AbortSignal.timeout(5000)
]);
fetch('https://jsonplaceholder.typicode.com/posts', {
signal: combinedSignal
})
.then(res => res.json())
.then(data => console.log('Success:', data.length, 'posts'))
.catch(err => console.log('Canceled due to:', err.name));
// The user clicks a "Cancel" button after 1 second:
setTimeout(() => manualController.abort(), 1000);

Since the manual abort fires at 1 second (well before the 5-second timeout), the fetch rejects and the console prints “Canceled due to: AbortError”. If you removed the setTimeout, the request would either succeed normally or fail at the 5-second mark with a TimeoutError. This pattern gives you the best of both worlds: user-initiated cancellation and automatic timeout protection.

Using AbortController with Event Listeners

A lesser-known but powerful feature is that addEventListener also accepts an AbortSignal. Instead of manually calling removeEventListener (which requires keeping a reference to the exact same function), you can pass a signal and abort it when you want to clean up.

const controller = new AbortController();

document.addEventListener('click', (event) => {
console.log('Click at:', event.clientX, event.clientY);
}, { signal: controller.signal });
document.addEventListener('keydown', (event) => {
console.log('Key pressed:', event.key);
}, { signal: controller.signal });
// Remove ALL listeners attached with this signal at once
setTimeout(() => {
controller.abort();
console.log('All event listeners removed.');
}, 5000);

For the first 5 seconds, every click logs its coordinates (e.g., “Click at: 245 312”) and every key press logs the key name (e.g., “Key pressed: Enter”). After 5 seconds, controller.abort() fires, and both listeners are automatically removed. The console prints “All event listeners removed.” and no further clicks or key presses produce output. This is a clean way to manage groups of related listeners, and it’s particularly useful in single-page applications where components mount and unmount dynamically.

Aborting Custom Async Operations

AbortController isn’t limited to fetch and event listeners. You can integrate it into any custom async function by listening for the abort event on the signal.


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

const timeoutId = setTimeout(resolve, ms);
// Listen for abort during the wait
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(new DOMException('Operation aborted', 'AbortError'));
}, { once: true });
});
}

// Usage:

const controller = new AbortController();

delay(5000, controller.signal)
.then(() => console.log('Delay completed!'))
.catch(err => console.log('Delay canceled:', err.message));
// Abort after 1 second
setTimeout(() => controller.abort(), 1000);

The delay function creates a 5-second timer, but the controller aborts after just 1 second. When the abort event fires, clearTimeout cancels the pending timer and the promise rejects with “Delay canceled: Operation aborted”. If you removed the abort call, after 5 seconds you would see “Delay completed!”. This pattern is reusable for any cancellable async operation file processing, animation loops, polling intervals, and more.

Common Pitfalls and Best Practices

(Pitfall) Once a controller has been aborted, its signal stays in the aborted state permanently. You must create a new AbortController for each new operation.

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

// This fetch will IMMEDIATELY fail because the signal is already aborted
fetch('https://jsonplaceholder.typicode.com/posts', {
signal: controller.signal
}).catch(err => console.log(err.name));

Prints “AbortError” instantly, because the signal was already aborted before the fetch even started. Always create a fresh controller for each new request.

(Pitfall) If you don’t check for AbortError, your error handling code might display cancellation messages as if they were real failures, confusing your users.

(Best Practice) Always check err.name === ‘AbortError’ and handle it as a silent, expected case.

(Best Practice) Use the { once: true } option when listening for the abort event on a signal to prevent memory leaks and ensure the listener fires only once.

AbortController is one of those Web APIs that might seem niche at first glance but quickly becomes indispensable once you understand its power. It solves real-world problems — race conditions in search inputs, zombie requests after navigation, unresponsive timeout handling — with a clean, standardized approach that works across fetch, event listeners, and custom async code.

The key takeaways are: always pass a signal to your fetch calls when cancellation is possible, use AbortSignal.timeout() for simple timeouts, combine signals with AbortSignal.any() for complex cancellation logic, leverage the signal option in addEventListener for clean event listener management, and always create a fresh controller for each new operation.

By incorporating AbortController into your daily workflow, you’ll build web applications that are more responsive, more efficient with network resources, and free from the subtle race condition bugs that plague applications without proper cancellation handling. It’s a small API with a big impact on code quality.


AbortController was originally published in Client-Side JavaScript on Medium, where people are continuing the conversation by highlighting and responding to this story.

Scroll to Top