Skip to content

Cancellation Patterns in TypeScript: Taming AbortController

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

4 min read
TypeScript code demonstrating AbortController cancellation chains across async operations

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.

tstypescript
// ❌ 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.

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

i

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.

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

tstypescript
// ❌ 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:

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

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

tstypescript
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

  1. Thread AbortSignal through every async function that does meaningful work — treat it like a required parameter, not an afterthought
  2. Check signal.throwIfAborted() before and between expensive steps, not just at function entry
  3. Compose cancellation sources with AbortSignal.any() instead of hand-rolling event listener chains
  4. Always clean up resources in finally, regardless of whether the function completed, errored, or was aborted
  5. Test both "already aborted" and "aborted mid-flight" paths — they exercise different code branches
  6. 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.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX