AbortController — cancelling async work the right way
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
1const controller = new AbortController();23fetch('/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 });1112// Cancel the request13controller.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:
1function UserProfile({ userId }) {2 const [user, setUser] = useState(null);34 useEffect(() => {5 const controller = new AbortController();67 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 });1718 return () => controller.abort();19 }, [userId]);2021 if (!user) return <p>Loading...</p>;22 return <h1>{user.name}</h1>;23}
That one-line cleanup covers two distinct scenarios:
- The component unmounts mid-flight — no stray
setUseron a dead component. userIdchanges 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:
1User clicks user 1 → fetch starts2User clicks user 2 → fetch starts3User 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
1async function fetchWithTimeout(url, timeoutMs = 5000) {2 const controller = new AbortController();3 const timeout = setTimeout(4 () => controller.abort(),5 timeoutMs6 );78 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.