I had a search page that fired a fetch on every keystroke. Worked fine until someone typed fast. Then the requests piled up, responses came back out of order, and the results showed garbage from three queries ago. Classic race condition, and I caused it myself.
The fix is AbortController. It is not new, but most people either do not know it exists or forget to use it.
The Problem
Every fetch() returns a promise. If you fire five in a row, they all run. There is no built-in cancellation. The browser keeps waiting for all of them, and whichever resolves last wins.
That means if you type "a", "ab", "abc" in quick succession, you might get results for "a" showing up after "abc" already returned. The user sees wrong data and thinks your app is broken.
The Basic Setup
Here is the simplest version:
const controller = new AbortController();
const signal = controller.signal;
fetch('/api/search?q=hello', { signal })
.then(res => res.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Request was cancelled');
} else {
console.error('Real error:', err);
}
});
// Cancel it
controller.abort();The signal object is what connects the controller to the fetch. You pass it in the options, and when you call abort(), the fetch throws an AbortError. That is it. No magic.
Using It in a Search Input
Here is the pattern I use for search-as-you-type:
let currentController = null;
async function search(query) {
// Abort the previous request if it exists
if (currentController) {
currentController.abort();
}
// Create a new controller for this request
currentController = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: currentController.signal
});
const data = await res.json();
renderResults(data);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Search failed:', err);
}
}
}
// Debounce wrapper
let timeout = null;
input.addEventListener('input', (e) => {
clearTimeout(timeout);
timeout = setTimeout(() => search(e.target.value), 300);
});Every new search aborts the old one. I also added a debounce because aborting five requests per second is wasteful even if it works. 300ms is the sweet spot for search inputs, at least for me.
Cancelling on Navigation
If a user navigates away while a request is in flight, you want to cancel it. Otherwise you get errors in the console from requests that do not matter anymore.
// Store controllers globally so you can clean up
const pendingRequests = new Set();
function trackedFetch(url, options = {}) {
const controller = new AbortController();
options.signal = controller.signal;
pendingRequests.add(controller);
return fetch(url, options).finally(() => {
pendingRequests.delete(controller);
});
}
// Cancel everything when leaving the page
window.addEventListener('beforeunload', () => {
pendingRequests.forEach(c => c.abort());
pendingRequests.clear();
});Timeouts With AbortSignal.timeout()
There is also AbortSignal.timeout() which creates a signal that aborts after a given time. Useful when you do not want to manage a controller yourself.
// Abort after 5 seconds
const res = await fetch('/api/slow-endpoint', {
signal: AbortSignal.timeout(5000)
}).catch(err => {
if (err.name === 'TimeoutError') {
console.log('Request timed out');
}
throw err;
});This is cleaner than setTimeout + abort for simple cases. But if you need to abort manually too, stick with AbortController.
Combining Signals
You can combine multiple signals with AbortSignal.any(). If any of them aborts, the fetch cancels.
const userController = new AbortController();
// Cancel if user clicks cancel OR if it takes more than 10s
const combined = AbortSignal.any([
userController.signal,
AbortSignal.timeout(10000)
]);
const res = await fetch('/api/data', { signal: combined });I use this for file uploads. The user can cancel manually, but it also times out if the server is stuck.
Gotchas
AbortError is not a real error. It is expected behavior. Always check err.name === 'AbortError' before logging or showing an error to the user.
Aborting a fetch does not stop the server. The server still processes the request. You just stop waiting for the response. If you have expensive server-side work, you need your own cancellation logic on the backend.
Also, once you abort a signal, you cannot reuse it. You need a new controller for each request. I spent an hour debugging that once.
Conclusion
AbortController is not glamorous. It is one of those APIs you learn once and then sprinkle everywhere. If you are doing any kind of search-as-you-type, live updates, or anything where requests can become stale, use it. Your users will not notice, but your console will be quieter.
:)