Skip to content
Chapter 7Lesson 4

Cancellation with AbortController and AbortSignal

How to stop in-flight async work in JavaScript with AbortController and AbortSignal, the cancellation primitive every web and Node API in the stack relies on.

A user types into a search box. The first keystroke is r, and your code fires fetch('/api/search?q=r'). By the time that request lands the user has typed re, rea, reac, react: five characters in roughly a second, five requests in flight. The fast network mostly returns them in order, but not always. The response for rea happens to be served from a cold cache, so it lands after the response for react. Your dropdown renders whichever response arrives last, so it now shows results for rea while the user stares at the word react. The bug is reproducible, embarrassing, and not the user’s fault.

The fix isn’t to debounce harder or to sort responses by timestamp. The fix is to cancel the obsolete requests the moment they become obsolete. The 2026 mechanism for that has two parts. AbortController creates the cancellation token, and AbortSignal is the read-only view that consumers listen to. This pair shows up everywhere. fetch takes a signal, and so does the abortable Node variant of setTimeout, and addEventListener, and the Vercel AI SDK’s streamText. Drizzle queries pass a signal to the underlying pg driver, and every Server Action this course writes accepts one. By the end of this lesson, when you see an async function that does any kind of I/O, you should expect a signal parameter and notice when it isn’t there.

The mental model is two parties with one wire between them. On one side is the code that decides when to cancel: the component, the parent function, the request handler. On the other side is the code doing the work: fetch, the timer, the database driver. They are kept separate on purpose. The party doing the work has no business deciding when to stop, and the party that decides doesn’t get to reach into the work and yank it.

The two types encode that separation. AbortController is the producer. The caller creates one, holds the reference, and calls controller.abort() when it wants the operation to stop. AbortSignal is the consumer-facing view, which you reach through controller.signal. The signal is read-only, with no abort() method of its own, and that is exactly what makes it safe to hand out. Once the controller fires, the signal’s aborted property flips to true, its 'abort' event dispatches, and every consumer wired to that signal sees the same flip at the same time.

The split is also what makes composition possible. Because the signal is a value you can pass around independently of its controller, you can build a signal that aborts when any of several signals abort, without ever exposing the underlying controllers. That composition shape arrives later in the lesson. For now, the diagram below shows the picture that matters: one producer fanning out to many consumers through a single signal value.

your code the caller that decides when
AbortController .signal
consumer fetch(url, { signal })
consumer addEventListener('click', fn, { signal })
consumer setTimeout(1_000, undefined, { signal })
One producer, one signal, many consumers. The controller is held privately; the signal is the value you pass around.

In code, the controller side fits on two lines:

const controller = new AbortController();
// later, when we want to stop
controller.abort();

Two properties on the signal are worth knowing, because they show up when you write the consumer side of long-running work. signal.aborted is a boolean: false before the abort, true after. signal.reason is whatever value you passed to abort(reason), or a default DOMException if you called abort() with no argument. A long-running loop can guard against a mid-flight abort by checking if (signal.aborted) break between iterations. You won’t reach for these often, because most of the time you thread the signal into an API that does the listening for you, but they are there when you need them.

Now look at the same parameter on four different async surfaces. The point of the next block isn’t four separate examples; it’s one shape, repeated, until it becomes muscle memory. Click through the tabs.

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'.

Read the four tabs again as one statement and you have the rule: if an async function does I/O, its signature includes signal. The project’s default parameter shape for optional cancellation is { signal }: { signal?: AbortSignal } in an options object, exactly as written on the fourth tab. Even when the immediate caller has no reason to cancel, the function still accepts the parameter, because the moment some future caller does need to cancel, you don’t want to refactor the signature.

Now put the search-suggestions example together end to end. It has two parts: the function that does the work, which accepts a signal and threads it to fetch, and the caller that holds the controller, which aborts the previous controller on each keystroke and creates a fresh one for the new request. The React-side wiring, holding the controller in a ref and aborting on unmount, arrives in the React effects chapter. Right now you’re learning the function shape that React code will eventually call into. Step through the annotated walkthrough below.

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 project’s canonical shape: optional, typed as AbortSignal. Every async helper that does I/O follows this shape, so the moment 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 knows how to listen. fetch accepts it directly. If this function also queried the database or read a file, those calls would take the same signal too.

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 keystroke kicked off. The previous in-flight fetch rejects with AbortError the moment this line runs. The ?. is defensive for the first call when there’s no previous controller yet.

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 are not reusable. Once aborted, they stay aborted. One controller per logical operation; here, one per keystroke. The previous one becomes garbage as soon as the 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 rejects. The difference is intent: this cancellation was on purpose, triggered by the user typing again, so treat it as a no-op. Real failures get rethrown. Once you add timeouts in the next section, this catch grows a second branch for TimeoutError. The section after that explains why this check uses err.name and not instanceof DOMException.

1 / 1

Two things are worth noticing about this shape. First, the function and the caller are decoupled. The function takes a signal and doesn’t know or care who created the controller, or whether there even is one. That is what makes the function reusable from React, from a Server Action, from a CLI, or from a test: anywhere a caller can produce an AbortSignal. Second, every keystroke gets a brand new controller. Controllers aren’t reusable. Once you abort one, calling abort() again is a no-op and its signal stays aborted forever, so trying to share one controller across requests is a common early mistake.

Most 2026 tutorials skip this part, but it is where cancellation actually pays off. Three different conditions can cause an awaited operation to reject before it finishes successfully, and your catch block has to tell them apart. Get this wrong and you either silently swallow real failures or you spam the console with noise on every keystroke. Here are the three conditions, in order:

  1. User-cancel. Someone called controller.abort(). The awaited operation rejects with an error whose name is 'AbortError' . This is intentional, so treat it as a no-op and return early. Don’t log it; the user just moved on.
  2. Timeout. The signal came from AbortSignal.timeout(ms), covered in the next section. The rejection has a name of 'TimeoutError' . This is also intentional, but the user probably wants to know, so surface a message like “took too long, try again.”
  3. Real failure. Anything else: a network error, a 500 from the server, a JSON parse error, or a runtime exception in the response handler. Propagate it so the framework’s error boundary or your top-level handler sees it.

Tutorial code routinely conflates the first two, assuming that any abort produces an AbortError, and that is the gotcha of the lesson. AbortSignal.timeout(ms) deliberately rejects with TimeoutError, not AbortError. The reason is a difference in user experience: a user-initiated cancel is silent and intentional, while a timeout is unintentional and probably worth telling the user about. Your catch has to handle both, separately.

Look at the same try/catch written two ways. The first tab is what most tutorials show, and it is wrong in production. The second is the form the project uses.

try {
await searchSuggestions(query, { signal });
} catch (err) {
console.error('search failed', err);
}

Every keystroke logs an error. Each new keystroke aborts the previous request, which rejects and lands here as a “real” failure, so the console fills with noise. Worse, when an actual network failure occurs, it drowns in the same stream and you’ll never spot it. The catch block has to discriminate.

One reasonable question: why err.name === 'AbortError' rather than err instanceof DOMException? Because DOMException is a browser type. The same logical condition, “the work was aborted because we asked it to be,” can be thrown by Node’s pg driver, by node:fs, by the Vercel AI SDK, or by a database adapter, and those throws can be different concrete classes that share the same name string. The name check is the form that holds up across the SaaS stack. The instanceof DOMException check works in the browser but breaks the moment you move the helper to a Server Action.

The fuller story on narrowing unknown in catch blocks, and the ensureError normalizer that wraps non-Error throws, arrives in the next chapter. For now, err instanceof Error && err.name === '...' is enough.

In the previous lesson on Promise combinators you saw Promise.race, and you might have wondered about its canonical use case: racing a fetch against a timer Promise. That pattern is retired in 2026. The reason is one line of code:

const res = await fetch('/api/slow', { signal: AbortSignal.timeout(5_000) });

AbortSignal.timeout(ms) is a static factory. It returns a fresh AbortSignal that aborts itself after ms milliseconds. There is no controller to hold, no clearTimeout to remember, and no Promise.race to assemble. The timer cleans itself up if the request settles first, and the signal becomes garbage as soon as the call returns. One line replaces the old four-step dance.

You already saw the gotcha: when the timeout fires, the rejection’s name is 'TimeoutError', not 'AbortError'. That isn’t an inconsistency. It’s a deliberate signal to your catch, which uses the two names to tell two situations apart. “The user moved on” is silent, and “the network is too slow” is something the user should hear about. This is exactly the discrimination the previous section set up.

Composing signals with AbortSignal.any([...])

Section titled “Composing signals with AbortSignal.any([...])”

A typical SaaS request 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. You want all three reasons to be able to terminate the same operation, and you want one catch block to handle all of them. AbortSignal.any([...]) is the composition primitive for that: give it an array of signals, and you get back a single signal that aborts the moment any input signal aborts.

Here is the canonical shape this course uses everywhere, in Server Actions, in background workers, in AI streaming, anywhere a single async operation has multiple legitimate reasons to stop:

const signal = AbortSignal.any([
userController.signal,
AbortSignal.timeout(30_000),
shutdownSignal,
]);
const result = await fetch('/api/ai/stream', { signal });

Three composition sources, each with a real trigger:

  • User cancellation: userController is held wherever the cancel UI lives. The user closes the tab, clicks Stop, or navigates away, and the component’s effect aborts the controller as it tears down.
  • Deadline: AbortSignal.timeout(30_000) caps the operation at 30 seconds. If the upstream service hangs, the server gives up instead of holding the request socket open indefinitely.
  • Shutdown: shutdownSignal is a process-wide signal that fires when the server receives SIGTERM for a graceful shutdown. In-flight work is allowed to drain before the process exits. The wiring arrives in a later deployment chapter, so for now just recognize the shape.

The composed signal carries the reason from whichever input fired first, so inside your catch the same name-based discrimination still works. If user-cancel fired first, the name is 'AbortError'; if the timeout fired first, the name is 'TimeoutError'. You don’t need to know which input fired, because the rejection tells you.

One more factory is worth naming. AbortSignal.abort(reason?) returns an already-aborted signal. Use it to short-circuit an operation the caller already knows it doesn’t want: passing a pre-aborted signal causes the API to reject immediately without doing any work. It’s useful in tests, and occasionally useful for guard logic.

Here are two contracts, stated plainly so you don’t reach for the wrong tool when one of them isn’t enough.

Guaranteed. When you call controller.abort(), the signal’s 'abort' event fires synchronously. Every consumer using { signal } stops its pending work: fetch aborts the in-flight request, the abortable timer’s wait is interrupted, and the registered event listener is removed. The Promise the operation returned rejects, with 'AbortError' for a user-cancel or 'TimeoutError' for a timeout, and your catch runs.

Not guaranteed. Work that already completed is not reversed. If your fetch already received a 200 and the server already inserted a row into the database, that row is committed; aborting the signal now cancels nothing, because the work is done. If your code called a Stripe charge that already settled, the charge has settled. Cancellation prevents further work, not work already done.

The underlying idea is that the unit of cancellation is the work, not the Promise. A Promise is just a value that tells you when the work finished and how. Cancelling the Promise itself is meaningless, because JavaScript has no Promise-level “undo,” by design. What you cancel is the operation behind it.

When you actually need to undo what was already done, you reach for a different tool. One is a transaction that rolls back if anything inside it fails, covered in the database chapter on transactions. Another is a compensating action that explicitly reverses a previous step, covered in the background-work chapter. Cancellation is the cheap, structural mechanism for stopping future work. Transactions and compensating actions are the expensive, deliberate mechanisms for undoing past work.

Here is a fetchUser helper that does none of what you just learned: no signal parameter, no discrimination at the catch, every error path treated the same. Your job is to add the cancellation plumbing so it behaves correctly under all three “didn’t finish” conditions.

The tests pre-construct different signals to drive each scenario. A normal call should return the user object. An already-aborted controller should make the helper return null, the test’s stand-in for “user moved on.” An AbortSignal.timeout(0) should make it return null as well, the stand-in for “show the timeout message.” An HTTP 500 should propagate.

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 signature carries the optional signal in the canonical options-object shape, and the fetch call threads it through to the I/O. The catch block discriminates by err.name: AbortError and TimeoutError are intentional stops, so they return null, and anything else is a real failure that propagates.

    The next exercise pairs five scenarios with five tools. Matching each trigger to the right API surface is how this lesson’s vocabulary becomes something you reach for without thinking.

    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.

    User types a new search query; cancel the previous request
    controller.abort() + a fresh AbortController per call
    A request taking longer than 30 seconds should fail
    AbortSignal.timeout(30_000)
    Compose user-cancel, timeout, and shutdown into one cancel source
    AbortSignal.any([...])
    A one-shot click listener that auto-removes when its signal fires
    addEventListener('click', fn, { signal })
    Pass an already-aborted signal to skip the call entirely
    AbortSignal.abort()

    You now have the 2026 cancellation toolbox in one piece: the producer-consumer split, the { signal } parameter shape, the canonical user-cancel pattern, name-based discrimination at the catch, AbortSignal.timeout(ms) for deadlines, and AbortSignal.any([...]) for composition. From here, every chapter that adds a new async surface, whether fetch proper, Drizzle queries, Server Actions, or AI streaming, builds on this foundation. The habit to install is small and uniform: if it does I/O, it takes a signal.

    The React-side wiring that holds the controller in a ref and aborts on effect cleanup arrives in the chapter on React effects. Server-side cancellation through Server Actions and route handlers arrives in the Server Actions and route-handler chapters. AI streaming cancellation arrives in the AI chapter. All of them assume what you just learned.