Mastering AbortController: Cancel Fetch & Axios Requests for Better Performance

Mastering AbortController: Cancel Fetch & Axios Requests for Better Performance
API requests running after users navigate away? Learn how AbortController gives you full control over fetch and Axios requests, prevents memory leaks, and improves your app's performance.
Mohammad Alhabil
Author
Mastering AbortController: Cancel Fetch & Axios Requests for Better Performance
How many times have you noticed API requests in your project still running even after the user:
- Changed the page
- Closed a modal
- Typed a new search query before the response arrived
All these requests consume device memory, put pressure on the server, and worst of all... they might return stale data that ruins the user experience!
This is where AbortController comes in.
Why Use AbortController?
| Benefit | Description |
|---|---|
| ๐ฎ Full Request Control | Stop any API request whenever you want |
| โก Performance Boost | No delayed responses messing up the UI |
| ๐ง Memory Protection | Prevent memory leaks from hanging requests |
| ๐ Faster UX | New requests start immediately without waiting for old ones |
1๏ธโฃ With Fetch API
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('Request was cancelled');
} else {
console.error('Fetch failed:', error);
}
});
// To cancel the request:
controller.abort();
How it works: Every time the component unmounts or changes, the current request stops immediately.
2๏ธโฃ With Axios: From CancelToken to AbortController
The Old Way (Deprecated)
// โ OLD - Don't use this anymore
const source = axios.CancelToken.source();
axios.get('/api/data', { cancelToken: source.token });
source.cancel('Request cancelled');
The New Way (v0.22.0+)
Since Axios version v0.22.0, CancelToken is deprecated. Now it's simpler, exactly like fetch:
// โ
NEW - Use AbortController
const controller = new AbortController();
axios.get('/api/data', { signal: controller.signal })
.then(response => console.log(response.data))
.catch(error => {
if (axios.isCancel(error)) {
console.log('Request was cancelled');
} else {
console.error('Request failed:', error);
}
});
controller.abort();
The advantage: Unified approach whether you're using fetch or axios!
Handling Cancellation Errors
It's crucial to handle cancellation errors separately to avoid showing fake errors to users:
| Library | Error Identifier | Check Method |
|---|---|---|
| Fetch | AbortError | error.name === 'AbortError' |
| Axios | CanceledError | axios.isCancel(error) |
// Fetch error handling
try {
const response = await fetch(url, { signal: controller.signal });
const data = await response.json();
} catch (error) {
if (error.name === 'AbortError') {
// Silently ignore - this is expected behavior
return;
}
// Handle actual errors
console.error('Request failed:', error);
}
// Axios error handling
try {
const { data } = await axios.get(url, { signal: controller.signal });
} catch (error) {
if (axios.isCancel(error)) {
// Silently ignore - this is expected behavior
return;
}
// Handle actual errors
console.error('Request failed:', error);
}
Real-World Examples
1. Live Search with Debouncing
Cancel the old request when the user types a new character:
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if still pending
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (!query.trim()) {
setResults([]);
return;
}
// Create new AbortController
abortControllerRef.current = new AbortController();
setIsLoading(true);
const fetchResults = async () => {
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: abortControllerRef.current!.signal }
);
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search failed:', error);
}
} finally {
setIsLoading(false);
}
};
// Debounce the search
const timeoutId = setTimeout(fetchResults, 300);
return () => {
clearTimeout(timeoutId);
abortControllerRef.current?.abort();
};
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{isLoading && <p>Searching...</p>}
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
2. Page Navigation Cleanup
No need to continue loading data for the previous page:
function ProductPage({ productId }: { productId: string }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
async function fetchProduct() {
try {
const response = await fetch(
`/api/products/${productId}`,
{ signal: controller.signal }
);
const data = await response.json();
setProduct(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to fetch product:', error);
}
} finally {
setLoading(false);
}
}
fetchProduct();
// Cleanup: abort when productId changes or component unmounts
return () => controller.abort();
}, [productId]);
if (loading) return <Skeleton />;
return <ProductDetails product={product} />;
}
3. Modal Form Cleanup
Save device resources when the user closes the modal:
function SubmitModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [isSubmitting, setIsSubmitting] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
const handleSubmit = async (formData: FormData) => {
// Cancel any previous submission
controllerRef.current?.abort();
controllerRef.current = new AbortController();
setIsSubmitting(true);
try {
await fetch('/api/submit', {
method: 'POST',
body: formData,
signal: controllerRef.current.signal,
});
onClose();
} catch (error) {
if (error.name !== 'AbortError') {
alert('Submission failed');
}
} finally {
setIsSubmitting(false);
}
};
// Cleanup when modal closes
useEffect(() => {
if (!isOpen) {
controllerRef.current?.abort();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="modal">
<form onSubmit={handleSubmit}>
{/* form fields */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
</div>
);
}
Pro Tips
1. Use useRef to Persist the Controller
// โ
CORRECT - Controller persists across renders
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
controllerRef.current = new AbortController();
fetch(url, { signal: controllerRef.current.signal });
return () => controllerRef.current?.abort();
}, [url]);
2. Create a Custom Hook for Reusability
function useAbortableFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Usage
function MyComponent() {
const { data, loading, error } = useAbortableFetch<User>('/api/user');
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <UserProfile user={data} />;
}
3. With React Query - Automatic but Customizable
React Query handles cancellation automatically, but you can integrate AbortController for finer control:
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
// React Query passes the signal automatically!
const response = await fetch('/api/todos', { signal });
return response.json();
},
});
4. Prevent Race Conditions
Use AbortController to ensure UI updates with the latest data only:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
let isCurrent = true; // Extra safety flag
async function fetchUser() {
try {
const response = await fetch(
`/api/users/${userId}`,
{ signal: controller.signal }
);
const data = await response.json();
// Only update if this is still the current request
if (isCurrent) {
setUser(data);
}
} catch (error) {
if (error.name !== 'AbortError' && isCurrent) {
console.error(error);
}
}
}
fetchUser();
return () => {
isCurrent = false;
controller.abort();
};
}, [userId]);
return user ? <div>{user.name}</div> : <Skeleton />;
}
Important Technical Notes
| Consideration | Details |
|---|---|
| ๐ Network Level Only | AbortController cancels at the network level; you may still need to update UI state |
| ๐ฆ One-time Use | Each AbortController can only be used once; create a new one for each request |
| ๐ Cleanup in useEffect | Always abort in the cleanup function to prevent memory leaks |
| โ๏ธ React 18+ | StrictMode calls effects twice; AbortController handles this gracefully |
Quick Reference
| Scenario | Implementation |
|---|---|
| ๐ Live search | Abort previous on new keystroke |
| ๐ Page navigation | Abort on unmount |
| ๐ Modal/Form submit | Abort on close |
| ๐ Polling/Intervals | Abort on cleanup |
| ๐ Race conditions | Abort + flag check |
Interfaces you love, code you understand. ๐ก
Topics covered
Found this article helpful?
Share it with your network and help others learn too!

Written by Mohammad Alhabil
Frontend Developer & Software Engineer passionate about building beautiful and functional web experiences. I write about React, Next.js, and modern web development.
