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
1const controller = new AbortController();
2
3fetch('/api/data', { signal: controller.signal })
4 .then((response) => response.json())
5 .then((data) => console.log(data))
6 .catch((error) => {
7 if (error.name === 'AbortError') {
8 console.log('Request was cancelled');
9 }
10 });
11
12// Cancel the request
13controller.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
1function UserProfile({ userId }) {
2 const [user, setUser] = useState(null);
3
4 useEffect(() => {
5 const controller = new AbortController();
6
7 fetch(`/api/users/${userId}`, {
8 signal: controller.signal,
9 })
10 .then((res) => res.json())
11 .then((data) => setUser(data))
12 .catch((error) => {
13 if (error.name !== 'AbortError') {
14 console.error('Fetch failed:', error);
15 }
16 });
17
18 return () => controller.abort();
19 }, [userId]);
20
21 if (!user) return <p>Loading...</p>;
22 return <h1>{user.name}</h1>;
23}

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
1User clicks user 1 → fetch starts
2User clicks user 2 → fetch starts
3User 2 response arrives → displays user 2
4User 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
1async function fetchWithTimeout(url, timeoutMs = 5000) {
2 const controller = new AbortController();
3 const timeout = setTimeout(
4 () => controller.abort(),
5 timeoutMs
6 );
7
8 try {
9 const response = await fetch(url, {
10 signal: controller.signal,
11 });
12 return await response.json();
13 } catch (error) {
14 if (error.name === 'AbortError') {
15 throw new Error('Request timed out');
16 }
17 throw error;
18 } finally {
19 clearTimeout(timeout);
20 }
21}

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.