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
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 requestcontroller.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:
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:
- 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:
User clicks user 1 → fetch startsUser clicks user 2 → fetch startsUser 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
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.