Using AbortController to Cancel fetch Requests in Single-Page Apps
JavaScript promises run to completion, but AbortController lets you cancel in-flight fetch requests when a component unmounts, saving bandwidth and preventing stale state updates.
04 Nov 2025, 03:35 UTC

The request that outlives its component
You build a dashboard. A user clicks a tab, a fetch starts, and before the response arrives they click another tab. The original request finishes anyway, downloading data nobody reads. Worse, if your .then() handler calls setState, React warns that you are updating an unmounted component. The promise does not care that the UI moved on.
Useful takeaway: AbortController gives you a standard way to tell cooperating async APIs—fetch included—to stop early when their result is no longer needed.
Why promises do not cancel on their own
A JavaScript promise represents an operation that will eventually settle: it either resolves with a value or rejects with a reason. Once created, there is no built-in cancel() method. A fetch call opens an HTTP connection and keeps it open until the server sends response headers and the body stream is consumed, or until the network itself fails. If the user navigates away in a single-page app (SPA), the old component may be gone, but the network request continues in the background.
That wasted bandwidth is only half the problem. The other half is memory: closures inside the .then() callback keep references to component state and props alive until the response arrives and the callback runs. In a long-lived SPA, repeated navigation can accumulate these stale closures.
How AbortController works
AbortController is a small built-in object that exposes a signal. You pass that signal to an async API that supports it. When you call controller.abort(), the signal's aborted property becomes true and an abort event fires. Cooperating operations—such as fetch—observe the signal and reject promptly with a DOMException whose name is AbortError.
The pattern is straightforward: create one controller per logical request, pass controller.signal in the options object, and call controller.abort() when the result is no longer needed.
Worked example: cancelling fetch in a React effect
In React, the natural place to cancel is the cleanup function returned from useEffect. If the component unmounts or a dependency changes, React runs that cleanup before the next effect.
import { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error('Network response was not ok');
const data = await res.json();
setUser(data);
} catch (err) {
if (err.name === 'AbortError') {
// The request was cancelled on purpose; ignore it.
return;
}
// Handle genuine errors here.
console.error(err);
}
}
loadUser();
return () => {
controller.abort();
};
}, [userId]);
if (!user) return <p>Loading…</p>;
return <div>{user.name}</div>;
}
Where to run it: any React application in a browser environment. No special permissions are required.
Expected check: open your browser's DevTools Network tab. When userId changes or the component unmounts, the pending request should show a canceled status. You should not see a React warning about setting state on an unmounted component.
Risk: if you omit the try/catch block, the AbortError rejection becomes an unhandled promise rejection in the console. Always catch it and check err.name.
Composing cancellation triggers
Modern JavaScript environments also provide static helpers on AbortSignal that reduce boilerplate. AbortSignal.timeout(ms) creates a signal that aborts automatically after a delay. AbortSignal.any([...]) combines multiple signals into one that aborts when any input aborts.
const timeoutSignal = AbortSignal.timeout(5000);
const res = await fetch('/api/report', { signal: timeoutSignal });
These helpers are available in recent browser and Node.js versions. Because support varies by engine and version, verify availability in your target environment before relying on them in production code.
Limitations and trade-offs
AbortController is not a universal kill switch. It only interrupts operations that explicitly check the signal. Synchronous work—tight for loops, heavy JSON parsing on the main thread, image decoding—cannot be stopped once it starts. Likewise, a promise that has already settled cannot be un-settled.
Ecosystem coverage is uneven. fetch, EventSource, and many Node.js streams accept a signal, but older libraries and custom XHR wrappers may ignore it entirely. Before standardizing on the pattern, audit your project's async dependencies to confirm which ones accept an options object containing a signal property.
There is also a minor performance consideration: creating a new AbortController for every request in a hot loop adds small overhead. When the cancellation scope is identical—for example, all requests tied to one component's lifetime—reuse a single controller.
Actionable next steps
- Wrap
fetchcalls in components with a per-effectAbortControllerand abort in cleanup. - Always catch rejections and filter out
AbortErrorby name. - Audit third-party libraries for
signalsupport before adopting the pattern broadly. - Use
AbortSignal.timeoutwhere supported; otherwise fall back to a manual controller plussetTimeout.
Cancelling stale requests is one of the cheapest ways to keep a single-page app responsive and free of zombie state updates. Start with the network calls that fire on rapid user interaction, and expand from there.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.