“The best async operation is the one you can cancel when you don’t need it anymore.”
Have you ever had a slow API call keep running after the user navigated away, or wished you could stop a file upload mid-way?
AbortController is how you cancel asynchronous operations in JavaScript, cleanly and without leaving work running behind you.
What AbortController Does
AbortController is a Web API that provides a signal-based mechanism for cancelling asynchronous tasks.
Think of it as a remote control for async operations. You can start a task, pass it a signal, and later call .abort() to stop it gracefully.
const controller = new AbortController();
const signal = controller.signal;
// Pass the signal to cancellable APIs
fetch('/api/data', { signal });
// Cancel later
controller.abort();When controller.abort() is called, the signal notifies all listeners.
APIs like fetch() will immediately reject with an AbortError.
Basic Usage with Fetch
async function fetchWithCancellation(url, controller) {
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was aborted');
return null;
}
throw error;
}
}
// Usage
const controller = new AbortController();
fetchWithCancellation('/api/data', controller);
// Cancel after 2 seconds
setTimeout(() => controller.abort(), 2000);Once aborted, a controller’s signal cannot be reused. Always create a new AbortController for each operation.
Timeout Handling
Manual Timeout Pattern
function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { signal: controller.signal })
.then((res) => {
clearTimeout(timeoutId);
return res;
})
.catch((err) => {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw err;
});
}Modern Alternative (Built-in Timeout)
// Supported in newer browsers
fetch('/api/slow', { signal: AbortSignal.timeout(5000) })
.then((res) => res.json())
.catch((err) => {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
console.warn('Request timed out');
}
});AbortSignal.timeout(ms) automatically aborts after the specified duration and eliminates manual cleanup.
User-Initiated Cancellation
class SearchManager {
constructor() {
this.controller = null;
}
async search(query) {
if (this.controller) this.controller.abort();
this.controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: this.controller.signal,
});
const results = await res.json();
console.log('Results:', results);
} catch (err) {
if (err.name !== 'AbortError') console.error('Search failed:', err);
}
}
cancel() {
if (this.controller) this.controller.abort();
}
}This pattern is ideal for live search or autocomplete inputs where new queries replace the previous ones.
File Upload with Progress and Cancellation
class FileUploader {
constructor() {
this.controller = null;
}
upload(file, onProgress) {
this.cancel(); // cancel previous upload if any
this.controller = new AbortController();
const { signal } = this.controller;
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
signal.addEventListener('abort', () => xhr.abort());
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(percent);
}
});
return new Promise((resolve, reject) => {
xhr.onload = () =>
xhr.status === 200
? resolve(JSON.parse(xhr.responseText))
: reject(new Error(`Upload failed: ${xhr.status}`));
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.open('POST', '/api/upload');
xhr.send(formData);
}).catch((err) => {
if (err.name === 'AbortError') console.log('Upload cancelled');
throw err;
});
}
cancel() {
if (this.controller) this.controller.abort();
}
}Coordinating Multiple Cancellable Tasks
class DataManager {
constructor() {
this.controllers = new Map();
}
async loadUserData(userId) {
const controller = new AbortController();
this.controllers.set(userId, controller);
try {
const [user, posts] = await Promise.all([
fetch(`/api/users/${userId}`, { signal: controller.signal }).then((r) => r.json()),
fetch(`/api/users/${userId}/posts`, { signal: controller.signal }).then((r) => r.json()),
]);
return { user, posts };
} catch (err) {
if (err.name === 'AbortError') console.log('Cancelled user data load');
else throw err;
} finally {
this.controllers.delete(userId);
}
}
cancel(userId) {
this.controllers.get(userId)?.abort();
this.controllers.delete(userId);
}
cancelAll() {
for (const c of this.controllers.values()) c.abort();
this.controllers.clear();
}
}Cleanup Pattern
class AsyncComponent {
constructor() {
this.controller = new AbortController();
this.timeouts = new Set();
}
async loadData() {
try {
const res = await fetch('/api/data', { signal: this.controller.signal });
return await res.json();
} catch (err) {
if (err.name === 'AbortError') return null;
throw err;
}
}
schedule(callback, delay) {
const id = setTimeout(() => {
this.timeouts.delete(id);
callback();
}, delay);
this.timeouts.add(id);
}
cleanup() {
this.controller.abort();
this.timeouts.forEach(clearTimeout);
this.timeouts.clear();
this.controller = new AbortController(); // ready for reuse
}
}Custom Abortable Operations
You can make any async operation abortable by observing the signal:
function delay(ms, signal) {
return new Promise((resolve, reject) => {
const id = setTimeout(resolve, ms);
signal.addEventListener('abort', () => {
clearTimeout(id);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
const controller = new AbortController();
delay(5000, controller.signal).catch(console.warn);
setTimeout(() => controller.abort(), 2000);Common Pitfalls
1. Not Handling AbortError
try {
await fetch(url, { signal });
} catch (err) {
if (err.name === 'AbortError') return; // graceful exit
throw err;
}2. Not Clearing Timeouts
Always clear timeouts after a fetch resolves or rejects.
3. Reusing an Aborted Signal
Never reuse an already aborted signal; always create a new controller.
Browser Notes
- Supported in all modern browsers: Chrome, Firefox, Safari, Edge.
AbortSignal.timeout()requires Chrome 115+, Firefox 122+, Safari 17+.- For older browsers, use the manual
setTimeoutpattern or a small polyfill.
When to Use AbortController
Use AbortController when:
- Users can navigate away from pages with pending requests.
- Implementing search or debounced API calls.
- Providing cancel buttons for uploads or downloads.
- Adding timeout behavior to prevent hanging requests.
- Cleaning up during component unmounts.
Avoid it for:
- One-off requests that don’t need cancellation.
- Critical operations that must complete.
- Non-cancellable system or background jobs.
Quick Reference
const controller = new AbortController();
const { signal } = controller;
fetch('/api', { signal })
.then((r) => r.json())
.catch((err) => {
if (err.name === 'AbortError') console.log('Aborted');
});
controller.abort();Respecting User Intent
Cancelling fetch requests is the obvious use for AbortController. The larger payoff is applications that respect user intent and manage resources well: operations cancelled gracefully, timeouts implemented safely, long tasks the user stays in control of, and async code that cleans up after itself.
Used thoughtfully, it makes your async logic easier to reason about and your applications feel faster.
