A product search box fires a request on every keystroke. The user types "lap", then "lapt", then "laptop". Three requests leave the browser; the one for "lap" is the slowest because it matches the most products, so it arrives last and overwrites the results for "laptop". The user sees phone cases and HDMI cables under a query for laptops, and the bug report says "search is random sometimes". Meanwhile the backend did three full-text queries when only one answer mattered.
On the server, the same shape shows up in reverse. A user opens a heavy report, gets bored after two seconds and closes the tab. Node keeps running the five downstream API calls and the database query for another twenty seconds, for a response nobody will read. Multiply that by a busy afternoon and you have a capacity problem made entirely of abandoned work.
The fix in both cases is the same primitive: AbortController. It is built into every modern browser and into Node.js, fetch understands it natively, and with AbortSignal.timeout and AbortSignal.any it covers timeouts and combined cancellation too.
How AbortController Works
The API is split into two objects with deliberately different powers:
AbortControlleris the remote control. Whoever holds it can callcontroller.abort(reason).AbortSignal(controller.signal) is the read-only receiver. You pass it to the work you want to be cancellable. It exposessignal.aborted,signal.reason,signal.throwIfAborted()and anabortevent.
That split matters: you hand the signal to a function, and the function can observe cancellation but cannot cancel anyone else. The caller keeps the controller.
A few rules worth memorising:
abort()is idempotent. The first call wins; later calls do nothing, andreasonkeeps the first value.abort()with no argument setsreasonto aDOMExceptionnamedAbortError.abort(reason)uses whatever you pass, which is handy for telling apart "user navigated away" from "superseded by a newer query".- A signal cannot be reset. Every operation that can be cancelled independently gets a new controller.
abortlisteners run synchronously insideabort(). Keep them small.
Search-as-You-Type, Step by Step
The rule is simple: when a new query starts, abort the previous one. Stale responses then never reach the code that renders results, because their promise rejects instead of resolving.
Cancelling fetch
fetch takes a signal option. When the signal aborts, the request is torn down (the browser closes the connection or cancels the HTTP/2 stream) and the promise rejects with the signal's reason. If the response headers already arrived, reading the body with res.json() rejects as well.
export function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}
export async function loadUser(id: string, signal?: AbortSignal) {
const res = await fetch(`/api/users/${id}`, { signal });
if (!res.ok) {
throw new Error(`Request failed with ${res.status}`);
}
return res.json();
}
Aborting is an expected outcome, not an error worth reporting. Filter it out before the error goes to Sentry or to a toast, otherwise every fast typist generates noise. Note what aborting does not do: if the server already received the request, it may still finish processing it. Cancellation saves the client from waiting and frees the connection; it is not a transaction rollback. Never rely on it to prevent a write from happening.
Timeouts with AbortSignal.timeout
fetch has no timeout option. Before AbortSignal.timeout, people wrapped fetch in Promise.race with a timer, which stopped the waiting but left the request running. Now there is a static helper that returns a signal which aborts by itself:
try {
const res = await fetch('/api/reports/monthly', { signal: AbortSignal.timeout(8000) });
const report = await res.json();
render(report);
} catch (error) {
if (error instanceof DOMException && error.name === 'TimeoutError') {
showMessage('The report is taking too long. Try a smaller date range.');
} else {
throw error;
}
}
The reason here is a DOMException named TimeoutError, not AbortError, so you can tell "we gave up" from "the user cancelled". In browsers the timer counts active time, so it is effectively paused while a page sits in the back/forward cache. AbortSignal.timeout is available in Node.js 17.3+ and all evergreen browsers.
Combining Signals with AbortSignal.any
Real code often has several reasons to stop: the user typed again, the component unmounted, the request took too long, the whole server is shutting down. AbortSignal.any([...]) returns a signal that aborts as soon as any input aborts, and its reason is the reason of the first one. It shipped in Node.js 20.3 and in all major browsers during 2023 and 2024.
type Result = { id: string; title: string };
export function createSearch(
fetchResults: (query: string, signal: AbortSignal) => Promise<Result[]>,
timeoutMs = 5000,
) {
let current: AbortController | null = null;
return async function search(query: string): Promise<Result[] | null> {
current?.abort();
const controller = new AbortController();
current = controller;
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
try {
return await fetchResults(query, signal);
} catch (error) {
if (controller.signal.aborted) {
return null;
}
throw error;
}
};
}
Look at the catch: it checks our own controller, not the combined signal. If this call was superseded, returning null tells the UI "ignore me". If the timeout fired, the TimeoutError propagates so the UI can show a message. Different reasons, different handling, one signal passed down.
React: Abort in the Effect Cleanup
In React the natural place for cancellation is the cleanup function of useEffect. It runs before the next effect and on unmount, which is exactly "a newer query started" and "nobody is listening anymore". Combine it with a small debounce so you also send fewer requests.
import { useEffect, useState } from 'react';
export function useSearch(query: string) {
const [results, setResults] = useState<{ id: string; title: string }[]>([]);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (query.trim().length < 2) {
setResults([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => {
fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => {
setResults(data);
setError(null);
})
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
});
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return { results, error };
}
This also makes the hook correct under React StrictMode in development, which mounts, unmounts and remounts effects on purpose: the first request is aborted by the cleanup and only the second one updates state. Data libraries like TanStack Query pass a signal into your query function for the same reason; forward it to fetch and you get cancellation for free.
Making Your Own Code Abortable
The signal only helps if the code that receives it listens. For your own async functions, follow two patterns: check signal.throwIfAborted() between steps, and turn timers or event waits into promises that reject on abort and clean up after themselves.
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(signal!.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function syncAll(ids: string[], signal: AbortSignal) {
for (const id of ids) {
signal.throwIfAborted();
await syncOne(id, { signal });
await sleep(100, signal);
}
}
Removing the listener when the timer wins is not cosmetic. A long-lived signal (for example, a server shutdown signal) would otherwise collect one listener per call forever, and Node prints a MaxListenersExceededWarning when that happens. The same signal can also clean up DOM listeners: element.addEventListener('scroll', handler, { signal }) removes the handler when the signal aborts.
On the server, wire the signal to the client connection so abandoned requests stop their downstream work:
app.get('/reports/:id', async (req, res, next) => {
const controller = new AbortController();
res.on('close', () => {
if (!res.writableEnded) controller.abort(new Error('client disconnected'));
});
try {
const report = await buildReport(req.params.id, { signal: controller.signal });
res.json(report);
} catch (error) {
if (controller.signal.aborted) return;
next(error);
}
});
Testing Cancellation with Vitest
Cancellation bugs are race conditions, so test the race explicitly: start two searches, make the first one slower, and assert that only the second one produces results.
import { describe, expect, it } from 'vitest';
import { createSearch, sleep } from './search';
describe('createSearch', () => {
it('aborts the previous request when a new query starts', async () => {
const signals: AbortSignal[] = [];
const search = createSearch((query, signal) => {
signals.push(signal);
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
setTimeout(() => resolve([{ id: query, title: query }]), query === 'lap' ? 50 : 10);
});
});
const first = search('lap');
const second = search('laptop');
await expect(first).resolves.toBeNull();
await expect(second).resolves.toEqual([{ id: 'laptop', title: 'laptop' }]);
expect(signals[0].aborted).toBe(true);
expect(signals[1].aborted).toBe(false);
});
});
describe('sleep', () => {
it('rejects with the abort reason', async () => {
const controller = new AbortController();
const pending = sleep(10_000, controller.signal);
controller.abort(new Error('stop'));
await expect(pending).rejects.toThrow('stop');
});
});
Common Pitfalls
- Reusing a controller. Once aborted, a signal stays aborted. Every new request that should be cancellable on its own needs a new
AbortController. - Reporting aborts as errors. Filter
AbortErrorbefore logging or showing an error toast. - Checking
error.nameonly forAbortError.AbortSignal.timeoutproducesTimeoutError, and a customabort(reason)produces whatever you passed. Decide based on which signal aborted when the difference matters. - Assuming abort undoes server work. The request may already be processed. Use idempotency keys for writes.
- Leaking listeners on long-lived signals. Use
{ once: true }and remove the listener on the success path. - Swallowing the signal. A function that accepts
signalbut never forwards it tofetchor checks it gives a false sense of safety.
When to Use It and When Not To
Use AbortController when:
- Responses can arrive out of order and only the latest matters: search, autocomplete, filters, tab switches.
- A component or page can go away while work is in flight.
- You need a real timeout on
fetchor on any API that accepts a signal (Node streams,timers/promises,events.once, many SDKs). - Server work can be abandoned when the client disconnects or the process is shutting down.
Do not reach for it when:
- The operation is a write that must complete once started. Make it idempotent instead, and let it finish.
- The work is synchronous and CPU-bound. A signal cannot interrupt a busy loop unless that loop checks
signal.aborted; move heavy work to a worker if it must be stoppable. - A request deduplicator or a data library already handles cancellation for you. Forward its signal rather than adding another layer.
Every fetch that can become irrelevant should carry a signal. It is one extra argument, and it removes an entire class of "random" bugs.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Implement your own version of AbortSignal.any. JavaScript: function anySignal(signals) takes an array of AbortSignal objects and returns a new AbortSignal (create it with your own AbortController). Python: the file already contains AbortError, AbortSignal (add_listener, remove_listener, listener_count, throw_if_aborted) and AbortController (abort(reason=None) calls each listener with the reason). Implement def any_signal(signals) -> AbortSignal. Rules for both: - If an input is already aborted, the returned signal is aborted immediately with that input's reason. - Otherwise the returned signal aborts the first time any input aborts, with exactly that input's reason. Later aborts of other inputs do not change it. - An empty list gives a signal that is not aborted. - When the combined signal aborts, remove the listeners you added to every input (Python checks this with listener_count()).
Starter code
/**
* Return an AbortSignal that aborts as soon as any input signal aborts,
* using that input's reason. Works like AbortSignal.any.
*/
function anySignal(signals) {
// TODO: create an AbortController for the combined signal
// TODO: if any input is already aborted, abort right away with its reason
// TODO: otherwise listen for 'abort' on every input; on the first one,
// remove all your listeners and abort with that input's reason
// TODO: return the controller's signal
throw new Error('TODO');
}
from collections.abc import Callable, Iterable
class AbortError(Exception):
pass
class AbortSignal:
def __init__(self) -> None:
self.aborted = False
self.reason: BaseException | None = None
self._listeners: list[Callable[[BaseException], None]] = []
def add_listener(self, fn: Callable[[BaseException], None]) -> None:
self._listeners.append(fn)
def remove_listener(self, fn: Callable[[BaseException], None]) -> None:
if fn in self._listeners:
self._listeners.remove(fn)
def listener_count(self) -> int:
return len(self._listeners)
def throw_if_aborted(self) -> None:
if self.aborted:
raise self.reason
class AbortController:
def __init__(self) -> None:
self.signal = AbortSignal()
def abort(self, reason: BaseException | None = None) -> None:
signal = self.signal
if signal.aborted:
return
signal.aborted = True
signal.reason = reason if reason is not None else AbortError("This operation was aborted")
listeners, signal._listeners = signal._listeners, []
for fn in listeners:
fn(signal.reason)
def any_signal(signals: Iterable[AbortSignal]) -> AbortSignal:
# TODO: create an AbortController for the combined signal
# TODO: if any input is already aborted, abort right away with its reason
# TODO: otherwise add a listener to every input; when the first one fires,
# remove ALL your listeners from every input, then abort with that input's reason
# TODO: return the controller's signal
raise NotImplementedError
Your solution
Hints
All hints revealedCheck every input for aborted before subscribing; return early with controller.abort(signal.reason).
Keep a list of (signal, handler) pairs so one cleanup function can remove all of them.
In Python, bind the loop variable as a default argument (source=signal) so each handler remembers its own input.
/**
* Return an AbortSignal that aborts as soon as any input signal aborts,
* using that input's reason. Works like AbortSignal.any.
*/
function anySignal(signals) {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
return controller.signal;
}
}
const subscriptions = [];
const unsubscribeAll = () => {
for (const [signal, handler] of subscriptions) {
signal.removeEventListener('abort', handler);
}
};
for (const signal of signals) {
const handler = () => {
unsubscribeAll();
controller.abort(signal.reason);
};
subscriptions.push([signal, handler]);
signal.addEventListener('abort', handler, { once: true });
}
return controller.signal;
}
from collections.abc import Callable, Iterable
class AbortError(Exception):
pass
class AbortSignal:
def __init__(self) -> None:
self.aborted = False
self.reason: BaseException | None = None
self._listeners: list[Callable[[BaseException], None]] = []
def add_listener(self, fn: Callable[[BaseException], None]) -> None:
self._listeners.append(fn)
def remove_listener(self, fn: Callable[[BaseException], None]) -> None:
if fn in self._listeners:
self._listeners.remove(fn)
def listener_count(self) -> int:
return len(self._listeners)
def throw_if_aborted(self) -> None:
if self.aborted:
raise self.reason
class AbortController:
def __init__(self) -> None:
self.signal = AbortSignal()
def abort(self, reason: BaseException | None = None) -> None:
signal = self.signal
if signal.aborted:
return
signal.aborted = True
signal.reason = reason if reason is not None else AbortError("This operation was aborted")
listeners, signal._listeners = signal._listeners, []
for fn in listeners:
fn(signal.reason)
def any_signal(signals: Iterable[AbortSignal]) -> AbortSignal:
signals = list(signals)
controller = AbortController()
for signal in signals:
if signal.aborted:
controller.abort(signal.reason)
return controller.signal
subscriptions: list[tuple[AbortSignal, Callable[[BaseException], None]]] = []
def unsubscribe_all() -> None:
for sig, handler in subscriptions:
sig.remove_listener(handler)
for signal in signals:
def handler(_reason: BaseException, source: AbortSignal = signal) -> None:
unsubscribe_all()
controller.abort(source.reason)
subscriptions.append((signal, handler))
signal.add_listener(handler)
return controller.signal

