Cancellation Patterns in TypeScript: Taming AbortController
Why most async TypeScript code ignores cancellation entirely, and how to build composable, leak-free cancellation with AbortController.

Most async code in a typical TypeScript codebase has no concept of "stop." A user navigates away, a request times out, a parent operation fails — and the in-flight fetch, database query, or background task just keeps running, burning CPU and holding connections open for work nobody wants anymore. Cancellation isn't an edge case. It's a first-class part of async design that gets bolted on too late, if at all.
AbortController has been available in Node.js and browsers for years, but most teams use it as a one-off fetch timeout trick instead of a systemic cancellation strategy. That's a missed opportunity.
Why Cancellation Gets Ignored
The default mental model for async/await treats a promise as a fire-and-forget commitment: once started, it runs to completion. There's no built-in way to tell a running async function "stop, I don't need this anymore." Without a signal to check, the function has no idea the caller has moved on.
// ❌ No way to stop this once it starts
async function fetchUserOrders(userId: string) {
const user = await db.users.findById(userId);
const orders = await db.orders.findByUserId(userId);
const enriched = await enrichWithShippingData(orders);
return enriched;
}
// If the caller times out or the request is aborted,
// this keeps running — three sequential queries, wasted work.The fix isn't a bigger try/catch. It's threading a signal through every layer that can take meaningful time.
The AbortSignal Contract
AbortController gives you a signal object that starts as "not aborted" and transitions once, permanently, to "aborted." Any function that does async work should accept a signal and check it — both before starting expensive work and while fetch/DB drivers support it natively.
async function fetchUserOrders(
userId: string,
signal?: AbortSignal,
): Promise<EnrichedOrder[]> {
signal?.throwIfAborted();
const user = await db.users.findById(userId, { signal });
signal?.throwIfAborted();
const orders = await db.orders.findByUserId(userId, { signal });
signal?.throwIfAborted();
return enrichWithShippingData(orders, signal);
}throwIfAborted() throws an AbortError (well, a DOMException with name "AbortError") immediately if the signal has already fired. It's cheap insurance against continuing work that's already pointless.
Most modern libraries — fetch, undici, pg (with a wrapper), Node's fs.readFile, child_process — accept a signal option. Check before writing your own polling loop.
Composing Timeouts and Manual Cancellation
Real systems need to combine multiple cancellation sources: a request-level timeout, a user-initiated cancel button, and a parent operation's own abort signal. AbortSignal.any() (Node 20+, all modern browsers) composes signals without manual event wiring.
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
const timeoutController = new AbortController();
const timer = setTimeout(() => timeoutController.abort(new Error("Timeout")), ms);
const combined = signal
? AbortSignal.any([signal, timeoutController.signal])
: timeoutController.signal;
// Clean up the timer once resolved either way to avoid leaking it
combined.addEventListener("abort", () => clearTimeout(timer), { once: true });
return combined;
}
// Usage: request-level abort AND a hard 5s ceiling
async function handleRequest(req: Request, requestSignal: AbortSignal) {
const signal = withTimeout(requestSignal, 5_000);
return fetchUserOrders(req.userId, signal);
}Before AbortSignal.any() existed, teams reimplemented this with manual addEventListener chains scattered across the codebase — inconsistent, and easy to leak listeners on. Composability is the whole point.
Cancellation in Loops and Batches
Long-running loops are where cancellation matters most, and where it's most often forgotten. Checking the signal only at the top of a function doesn't help if the function then iterates over 50,000 rows.
// ❌ Ignores the signal for the entire duration of the loop
async function processRecords(records: Record[], signal?: AbortSignal) {
for (const record of records) {
await processOne(record);
}
}
// ✅ Checks between iterations — bails out promptly, not eventually
async function processRecords(records: Record[], signal?: AbortSignal) {
for (const record of records) {
signal?.throwIfAborted();
await processOne(record);
}
}For CPU-bound synchronous loops (no await inside), checking every iteration is wasteful. Check every N iterations instead — the exact number depends on how expensive each iteration is:
function computeHashes(items: string[], signal?: AbortSignal): string[] {
const results: string[] = [];
for (let i = 0; i < items.length; i++) {
if (i % 1_000 === 0) signal?.throwIfAborted();
results.push(hash(items[i]));
}
return results;
}Cleaning Up Resources on Abort
Throwing on abort is only half the job. Whatever resources the operation acquired — file handles, DB transactions, temp files — need cleanup regardless of how the function exits. try/finally handles this, but it's easy to miss when a function has multiple early-return paths.
async function exportReport(query: ReportQuery, signal?: AbortSignal) {
const connection = await pool.acquire();
const tempFile = await createTempFile();
try {
signal?.throwIfAborted();
const rows = await connection.query(query.sql, { signal });
await writeRowsToFile(tempFile, rows, signal);
return tempFile;
} finally {
// Runs on success, on error, and on abort — no resource leak
await connection.release();
if (signal?.aborted) await deleteTempFile(tempFile);
}
}This is the same discipline as connection pooling — the finally block is your guarantee that cancellation doesn't turn into a resource leak.
Testing Cancellation Paths
Cancellation logic that's never tested is cancellation logic that's broken. Simulate abort with a pre-fired signal and a delayed one to catch both "already cancelled" and "cancelled mid-flight" cases.
import { test, expect } from "vitest";
test("throws immediately if already aborted", async () => {
const controller = new AbortController();
controller.abort();
await expect(
fetchUserOrders("user-1", controller.signal),
).rejects.toMatchObject({ name: "AbortError" });
});
test("stops mid-flight when aborted during execution", async () => {
const controller = new AbortController();
const promise = processRecords(bigRecordSet, controller.signal);
setTimeout(() => controller.abort(), 10);
await expect(promise).rejects.toMatchObject({ name: "AbortError" });
});Key Takeaways
- Thread
AbortSignalthrough every async function that does meaningful work — treat it like a required parameter, not an afterthought - Check
signal.throwIfAborted()before and between expensive steps, not just at function entry - Compose cancellation sources with
AbortSignal.any()instead of hand-rolling event listener chains - Always clean up resources in
finally, regardless of whether the function completed, errored, or was aborted - Test both "already aborted" and "aborted mid-flight" paths — they exercise different code branches
- Use interval-based checks in CPU-bound loops to avoid the overhead of checking on every iteration
Cancellation isn't a nice-to-have you add once someone complains about a runaway request. It's a signal that should exist wherever async work exists — because the moment you skip it, you've written code that assumes every operation you start is one you'll actually need to finish.


