Curriculum Series

AbortController — cancelling async work the right way

AbortController — cancelling async work the right way

AbortController — cancelling async work the right way

JavaScript

TL;DR

AbortController is the built-in way to cancel asynchronous work such as fetch. You create a controller, hand its signal to the operation, and call abort() whenever you need to pull the plug. In UI code — React especially — this is how you avoid memory leaks and race conditions when a component unmounts or its inputs change mid-flight.

The mechanics

javascript
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');
}
});
// Cancel the request
controller.abort();

Calling abort() rejects the fetch with an AbortError. Since the cancellation is intentional, you typically just swallow that particular error and let everything else bubble up.

In React — avoiding updates after unmount

The canonical use case is cleaning up a fetch inside useEffect:

javascript
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then((data) => setUser(data))
.catch((error) => {
if (error.name !== 'AbortError') {
console.error('Fetch failed:', error);
}
});
return () => controller.abort();
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}

That one-line cleanup covers two distinct scenarios:

  1. The component unmounts mid-flight — no stray setUser on a dead component.
  2. userId changes before the previous request resolves — the stale request gets cancelled, so an older response can't clobber a newer one.

Killing race conditions

Without cancellation, rapid navigation produces the classic out-of-order bug:

javascript
User clicks user 1 → fetch starts
User clicks user 2 → fetch starts
User 2 response arrives → displays user 2
User 1 response arrives (slower) → displays user 1 (wrong!)

Abort the first request when the second one kicks off and only the latest response ever lands in state.

With async/await

javascript
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
timeoutMs
);
try {
const response = await fetch(url, {
signal: controller.signal,
});
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timed out');
}
throw error;
} finally {
clearTimeout(timeout);
}
}

A neat side use: wire the abort to a setTimeout and you've got a timeout-aware fetch in a dozen lines.

Interview Tip

Lead with the React useEffect cleanup — it shows you understand both the unmount case and the dependency-change case in one example. Those two together demonstrate that you actually understand how the effect lifecycle intersects with in-flight work.

Why interviewers ask this

It surfaces in almost every conversation about data fetching, leaks, or race conditions. What they're really checking: can you reason about async work that outlives the code that started it, and do you clean up properly? It's a small API that prevents a big class of production bugs.

Finished reading?

Mark this topic as solved to track your progress.