Cancellation with AbortController and AbortSignal
Stop in-flight async work with AbortController and AbortSignal, the cancellation primitive every web and Node API in the stack relies on.
A user types react into a search box, one letter a second, and each keystroke fires a request: fetch('/api/search?q=r'), then q=re, q=rea, and so on.
Five requests are now in flight.
The network usually returns them in order, but not always: the response for rea is served from a cold cache and lands after the response for react.
Your dropdown renders whichever response arrives last, so it shows results for rea while the user stares at the word react.
The fix isn’t to debounce harder or sort responses by timestamp; it’s to cancel each request the moment it becomes obsolete.
The mechanism has two parts.
AbortController creates the cancellation token, and AbortSignal is the read-only view consumers listen to.
This pair shows up across the stack: fetch takes a signal, and so does every Server Action you’ll write.
By the end of this lesson, when you see an async function that does I/O, you should expect a signal parameter and notice when it’s missing.
The producer-consumer split
Section titled “The producer-consumer split”Cancellation splits into two roles.
One side decides when to cancel: the component, the parent function, the request handler.
The other does the work that gets cancelled: fetch, a timer, a database driver.
Keeping them apart means the worker never decides when to stop, and the decider never reaches in to halt it directly.
The two types encode that split.
AbortController is the producer: the caller creates one, holds it, and calls controller.abort() to stop the operation.
AbortSignal, reached through controller.signal, is the read-only view you hand to the worker.
It has no abort() of its own, which is what makes it safe to pass around.
Because the signal is a plain value independent of its controller, you can combine several signals into one that aborts when any of them does, without exposing the underlying controllers.
AbortController .signal fetch(url, { signal }) addEventListener('click', fn, { signal }) setTimeout(1_000, undefined, { signal }) In code, the producer side is two lines:
const controller = new AbortController();// later, when we want to stopcontroller.abort();On the consumer side, signal.aborted is false before the abort and true after, and signal.reason holds whatever you passed to abort(reason), or a default DOMException if nothing.
A long-running loop can check if (signal.aborted) break between iterations, but rarely needs to: most consumers thread the signal into an API that listens for them.
The { signal } parameter shape
Section titled “The { signal } parameter shape”Now look at the same parameter on four async surfaces — one shape, repeated until it becomes automatic.
const res = await fetch('/api/search?q=react', { signal });The browser’s fetch. Pass signal as an option. When the signal fires, the request aborts mid-flight and the await rejects with an error whose name is 'AbortError'.
import { setTimeout } from 'node:timers/promises';
await setTimeout(1_000, undefined, { signal });The Promise-based timer. Imported from node:timers/promises, not the global. The wait suspends the surrounding async function for one second and rejects with AbortError if the signal fires first. The global callback-style setTimeout takes no signal; for browser timeouts, reach for AbortSignal.timeout(ms) below.
button.addEventListener('click', handleClick, { signal });DOM events. The listener is registered now and automatically removed the moment the signal fires, so you skip the matching removeEventListener, and one signal can tear down many listeners at once.
async function searchSuggestions( query: string, { signal }: { signal?: AbortSignal },): Promise<Suggestion[]> { const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); if (!res.ok) throw new Error(`search failed: ${res.status}`); return res.json();}A helper you author. The same shape on the consuming end: an options object with an optional signal, threaded through to every I/O call. The project uses it for every async helper that touches the network or the database.
Read the four tabs as one statement and you have the rule: if an async function does I/O, its signature includes signal.
The project’s default shape is { signal }: { signal?: AbortSignal }, exactly as written on the fourth tab.
Accept the parameter even when the current caller never cancels, so a future caller that needs to won’t force a signature change.
The canonical user-cancel pattern
Section titled “The canonical user-cancel pattern”The search-suggestions example has two parts: a function that accepts a signal and threads it to fetch, and a caller that holds the controller, aborting the previous one and creating a fresh one per keystroke.
The React-side wiring — holding the controller in a ref, aborting on unmount — comes in the React effects chapter; here you’re building the function shape React will call into.
type Suggestion = { id: string; label: string };
async function searchSuggestions( query: string, { signal }: { signal?: AbortSignal },): Promise<Suggestion[]> { const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); if (!res.ok) throw new Error(`search failed: ${res.status}`); return res.json();}
let active: AbortController | null = null;async function onInputChange(query: string) { active?.abort(); active = new AbortController(); try { const results = await searchSuggestions(query, { signal: active.signal }); render(results); } catch (err) { if (err instanceof Error && err.name === 'AbortError') return; throw err; }}The signature carries the signal. The options object destructures signal with the canonical shape: optional, typed as AbortSignal. Every async helper that does I/O follows it, so when a caller needs to cancel, the API is already there.
type Suggestion = { id: string; label: string };
async function searchSuggestions( query: string, { signal }: { signal?: AbortSignal },): Promise<Suggestion[]> { const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); if (!res.ok) throw new Error(`search failed: ${res.status}`); return res.json();}
let active: AbortController | null = null;async function onInputChange(query: string) { active?.abort(); active = new AbortController(); try { const results = await searchSuggestions(query, { signal: active.signal }); render(results); } catch (err) { if (err instanceof Error && err.name === 'AbortError') return; throw err; }}Thread the signal through. The function doesn’t own the signal; it passes it to the work that listens. fetch accepts it directly, as would a database query or file read here.
type Suggestion = { id: string; label: string };
async function searchSuggestions( query: string, { signal }: { signal?: AbortSignal },): Promise<Suggestion[]> { const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); if (!res.ok) throw new Error(`search failed: ${res.status}`); return res.json();}
let active: AbortController | null = null;async function onInputChange(query: string) { active?.abort(); active = new AbortController(); try { const results = await searchSuggestions(query, { signal: active.signal }); render(results); } catch (err) { if (err instanceof Error && err.name === 'AbortError') return; throw err; }}Abort the previous controller. Each keystroke first cancels whatever the previous one started, rejecting the in-flight fetch with AbortError. The ?. covers the first call, when there’s no previous controller.
type Suggestion = { id: string; label: string };
async function searchSuggestions( query: string, { signal }: { signal?: AbortSignal },): Promise<Suggestion[]> { const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); if (!res.ok) throw new Error(`search failed: ${res.status}`); return res.json();}
let active: AbortController | null = null;async function onInputChange(query: string) { active?.abort(); active = new AbortController(); try { const results = await searchSuggestions(query, { signal: active.signal }); render(results); } catch (err) { if (err instanceof Error && err.name === 'AbortError') return; throw err; }}Fresh controller per request. Controllers aren’t reusable: once aborted, they stay aborted. Use one per logical operation, here one per keystroke. The previous controller becomes garbage as soon as this assignment overwrites it.
type Suggestion = { id: string; label: string };
async function searchSuggestions( query: string, { signal }: { signal?: AbortSignal },): Promise<Suggestion[]> { const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); if (!res.ok) throw new Error(`search failed: ${res.status}`); return res.json();}
let active: AbortController | null = null;async function onInputChange(query: string) { active?.abort(); active = new AbortController(); try { const results = await searchSuggestions(query, { signal: active.signal }); render(results); } catch (err) { if (err instanceof Error && err.name === 'AbortError') return; throw err; }}Discriminate at the catch. A cancelled request rejects, just like a network failure does. The difference is intent: this cancellation was deliberate, triggered by the user typing again, so treat it as a no-op and rethrow real failures. The next section adds a TimeoutError branch and explains why this check uses err.name rather than instanceof DOMException.
The function and caller are decoupled: the function takes a signal and doesn’t know who created the controller, which makes it reusable from React, a Server Action, a CLI, or a test — anywhere a caller can produce an AbortSignal.
Discriminating cancellation at the catch
Section titled “Discriminating cancellation at the catch”This is where cancellation pays off.
Three conditions can make an awaited operation reject, and your catch has to tell them apart.
Get it wrong and you either swallow real failures or spam the console on every keystroke.
- User-cancel. Someone called
controller.abort(). The operation rejects with an error whosenameis'AbortError'. Intentional, so return early without logging; the user just moved on. - Timeout. The signal came from
AbortSignal.timeout(ms), covered next. The rejection’snameis'TimeoutError'. Also intentional, but the user wants to know, so surface a message like “took too long, try again.” - Real failure. Anything else: a network error, a 500, a JSON parse error, a runtime exception in the response handler. Propagate it so the framework’s error boundary or your top-level handler sees it.
AbortSignal.timeout(ms) rejects with TimeoutError, not AbortError, because the two need different handling: a user cancel is silent, a timeout is worth telling the user about.
Here is the same try/catch written two ways, wrong then right.
try { await searchSuggestions(query, { signal });} catch (err) { console.error('search failed', err);}Every keystroke logs an error. Each keystroke aborts the previous request, which rejects and lands here as a “real” failure, filling the console with noise. An actual network failure drowns in the same stream, and you never spot it.
try { await searchSuggestions(query, { signal });} catch (err) { if (err instanceof Error && err.name === 'AbortError') return; if (err instanceof Error && err.name === 'TimeoutError') { notify('Search timed out, try again.'); return; } throw err;}Three branches, three intents. AbortError is the user, so return silently. TimeoutError is the deadline, so surface it. Anything else is unexpected, so rethrow it to the framework’s error boundary or your top-level handler.
Why err.name === 'AbortError' rather than err instanceof DOMException?
Because DOMException is a browser type.
“The work was aborted because we asked it to be” can come from Node’s pg driver, node:fs, or the Vercel AI SDK as different classes that share the same name string.
The name check holds up across the stack; instanceof DOMException works in the browser but breaks the moment you move the helper to a Server Action.
Narrowing unknown in catch blocks and the ensureError normalizer for non-Error throws arrive in the next chapter.
For now, err instanceof Error && err.name === '...' is enough.
Timeouts with AbortSignal.timeout(ms)
Section titled “Timeouts with AbortSignal.timeout(ms)”The Promise combinators lesson showed Promise.race, whose canonical use was racing a fetch against a timer Promise.
That pattern is retired; the replacement is one line:
const res = await fetch('/api/slow', { signal: AbortSignal.timeout(5_000) });AbortSignal.timeout(ms) is a static factory that returns a fresh AbortSignal, which aborts itself after ms milliseconds.
No controller to hold, no clearTimeout to remember, no Promise.race to assemble.
If the request settles first the timer cleans itself up, and the signal is garbage-collected once the call returns.
When the timeout fires, the rejection’s name is 'TimeoutError', not 'AbortError' — the deliberate difference your catch uses to tell a user cancellation apart from a slow network.
Composing signals with AbortSignal.any([...])
Section titled “Composing signals with AbortSignal.any([...])”A request often has more than one reason to be cancelled: the user clicked Stop, the server-side deadline expired, the process is shutting down and needs to drain.
AbortSignal.any([...]) composes them.
Give it an array of signals and you get back one that aborts the moment any input aborts, so a single catch handles every case.
Here is the canonical shape, used wherever an async operation has multiple legitimate reasons to stop — Server Actions, background workers, AI streaming:
const signal = AbortSignal.any([ userController.signal, AbortSignal.timeout(30_000), shutdownSignal,]);
const result = await fetch('/api/ai/stream', { signal });Three sources, each with a real trigger:
- User cancellation: the component holding
userControlleraborts it when the user clicks Stop, closes the tab, or navigates away. - Deadline:
AbortSignal.timeout(30_000)caps the operation at 30 seconds so a hung upstream service can’t hold the request socket open indefinitely. - Shutdown:
shutdownSignalfires onSIGTERMto let in-flight work drain before the process exits; its wiring arrives in a later deployment chapter.
The composed signal carries the reason from whichever input fired first, so the same name-based discrimination at your catch still works.
AbortSignal.abort(reason?) returns an already-aborted signal. Pass it to short-circuit an operation the caller already knows it doesn’t want — the API rejects immediately without doing any work, handy in tests and guard logic.
What cancellation does and does not do
Section titled “What cancellation does and does not do”Cancellation comes with two contracts. Knowing both keeps you from reaching for it when it cannot help.
Guaranteed. When you call controller.abort(), the signal’s 'abort' event fires synchronously, and every consumer using { signal } stops its pending work: fetch aborts the in-flight request, an abortable timer’s wait ends, a registered event listener is removed. The Promise rejects — 'AbortError' for a user-cancel, 'TimeoutError' for a timeout — and your catch runs.
Not guaranteed. Work that already completed is not reversed. If your fetch received a 200 and the server inserted a row, that row is committed; aborting now cancels nothing. Cancellation prevents further work, not work already done.
The unit of cancellation is the work, not the Promise. A Promise just tells you when the work finished and how; cancelling it is meaningless, since JavaScript has no Promise-level “undo.” What you cancel is the operation behind it.
To undo what already happened, reach for a heavier tool: a transaction that rolls back on failure (the database chapter) or a compensating action that explicitly reverses a previous step (the background-work chapter). Cancellation stops future work cheaply; undoing past work is always deliberate and expensive.
Practice: refactor a fetch helper
Section titled “Practice: refactor a fetch helper”This fetchUser helper ignores everything in this lesson: no signal parameter, every error path treated the same.
Add the cancellation plumbing so it handles all three “didn’t finish” cases correctly.
The tests drive each case with a different signal.
A normal call returns the user object.
An already-aborted controller returns null (“user moved on”), and so does AbortSignal.timeout(0) (“show the timeout message”). An HTTP 500 propagates.
Refactor fetchUser so it (1) accepts an optional signal in its options, (2) threads the signal through to fetch, (3) returns null on AbortError, (4) returns null on TimeoutError, (5) rethrows anything else. The tests pre-mock global fetch to simulate each case.
Reveal solution
async function fetchUser( id: string, { signal }: { signal?: AbortSignal },): Promise<{ id: string; name: string } | null> { try { const res = await fetch(`/api/users/${id}`, { signal }); if (!res.ok) throw new Error(`fetch failed: ${res.status}`); return res.json(); } catch (err) { if (err instanceof Error && err.name === 'AbortError') return null; if (err instanceof Error && err.name === 'TimeoutError') return null; throw err; }}The catch block discriminates by err.name: AbortError and TimeoutError are intentional stops that return null; anything else is a real failure and propagates.
Practice: match each scenario to its cancellation API
Section titled “Practice: match each scenario to its cancellation API”Pair each cancellation scenario with the API that handles it. Matching the trigger to the right tool turns this lesson’s vocabulary into recall.
Match each cancellation scenario to the canonical 2026 move. Click an item on the left, then its match on the right. Press Check when done.
controller.abort() + a fresh AbortController per callAbortSignal.timeout(30_000)AbortSignal.any([...])addEventListener('click', fn, { signal })AbortSignal.abort()Whatever async surface a later chapter adds, the habit transfers unchanged: if it does I/O, it takes a signal.
External resources
Section titled “External resources”The consumer-side reference, including the static factories `AbortSignal.timeout`, `AbortSignal.any`, and `AbortSignal.abort`.
The static method's reference, with the explicit note that it rejects with `TimeoutError` rather than `AbortError` — the gotcha this lesson centers on.
Artem Zakharchenko's tour of cancellable event listeners, fetch, streams, and even custom logic — including a Drizzle-style integration that mirrors the SaaS stack.
Jake Archibald's original write-up on the API's design: why `AbortController` won over the earlier TC39 cancellation proposals, with patterns for timeouts and shared signals.