Cancellation is one of the hardest parts of async programming. A request times out, a user navigates away, or an operation is superseded—and suddenly you have a stray promise completing in the background, a lingering timer, an open database connection, or a listener silently holding memory. The problem isn’t that cancellation is novel; it’s that most async code never properly handles it.

The JavaScript ecosystem has AbortController, a standard mechanism for propagating cancellation signals through fetch, timers, event listeners, and user-written async functions. But knowing the API and using it reliably across real code are different things. This article covers the practical patterns: how to wire AbortSignal through async chains, detect and avoid common leaks, and why structured concurrency (the approach borrowed from Python and Kotlin) is the conceptual framework that makes cancellation predictable.

The Problem: Cancellation Without Structure

Consider a typical web request function:

async function fetchUserWithPosts(userId: string) {
  const user = await fetch(`/api/users/${userId}`);
  const posts = await fetch(`/api/users/${userId}/posts`);
  return { user: await user.json(), posts: await posts.json() };
}

If the component unmounts while awaiting posts, the second fetch is never awaited, but the browser’s HTTP layer still processes it. The memory for that response is allocated, the body is parsed asynchronously, and listeners remain wired. Multiply this across a busy SPA and you accumulate resource leaks.

More insidious: a timeout library wraps the function in a Promise.race:

const race = Promise.race([
  fetchUserWithPosts(userId),
  new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))
]);

If the race times out and the caller stops listening, the fetch requests continue in the background. The timer is cleared (Race doesn’t leak that), but the HTTP requests are still live, and their responses still arrive, still parse, still trigger callbacks.

The core issue: without a cancellation mechanism, child operations have no way to know their parent has abandoned them.

AbortController and AbortSignal Basics

AbortController is a simple primitive: a holder that maintains an AbortSignal, and a method to abort it.

const controller = new AbortController();
const signal = controller.signal;

// Pass signal to fetch
const response = await fetch(url, { signal });

// Later, trigger cancellation
controller.abort();

// The signal is fired; the fetch is canceled

When abort() is called, the signal’s .aborted property becomes true, and all listeners on that signal receive an abort event. Fetch immediately cancels the HTTP request and rejects the promise with an AbortError.

The key insight: the signal is the object that propagates. It flows down into async functions, into fetch calls, into event listeners. The controller stays at the callsite where cancellation is decided.

This is practical in two shapes:

Shape 1: Fetch and built-ins (fetch, AbortSignal.timeout, setTimeout, EventTarget.addEventListener)

async function fetchWithTimeout(url: string, timeoutMs: number) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    return response;
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") {
      throw new Error(`Request timed out after ${timeoutMs}ms`);
    }
    throw err;
  }
}

Shape 2: AbortSignal.timeout (modern, cleaner)

async function fetchWithTimeout(url: string, timeoutMs: number) {
  try {
    const response = await fetch(url, {
      signal: AbortSignal.timeout(timeoutMs)
    });
    return response;
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") {
      throw new Error(`Request timed out after ${timeoutMs}ms`);
    }
    throw err;
  }
}

The second pattern is preferable—it’s shorter and the timeout is managed by the platform, not by manual timers.

Propagating Signals Through Async Chains

The pattern becomes powerful when you thread the signal through multiple async operations:

async function fetchUserWithPosts(userId: string, signal: AbortSignal) {
  const userResponse = await fetch(`/api/users/${userId}`, { signal });
  const user = await userResponse.json();

  const postsResponse = await fetch(`/api/users/${userId}/posts`, { signal });
  const posts = await postsResponse.json();

  return { user, posts };
}

// At the callsite, manage the controller
const controller = new AbortController();
const result = await fetchUserWithPosts("123", controller.signal);

// Caller can cancel at any time
setTimeout(() => controller.abort(), 5000);

If the signal is aborted, any active fetch operation tied to it immediately cancels. But crucially, if the abort happens after the first fetch completes but before the second starts, the second fetch will also immediately reject with AbortError (because the signal is already .aborted).

This is the safety model: the signal’s state is checked at every .then(), every await, every operation that respects it. Once aborted, it stays aborted, and all pending work tied to it fails fast.

Cleanup and Finally Blocks

Cancellation doesn’t automatically free all resources. Timers, event listeners, and open connections require explicit cleanup. Always use finally:

async function processStream(stream: ReadableStream, signal: AbortSignal) {
  const reader = stream.getReader();
  const listener = () => console.log("abort");
  signal.addEventListener("abort", listener);

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      // Process value
    }
  } finally {
    // Always clean up
    signal.removeEventListener("abort", listener);
    reader.releaseLock();
  }
}

A common leak: listeners on the signal that are never removed. If you attach a listener inside a function, remove it before the function returns.

Event target listeners on the signal can be added many times (e.g., in a loop), and each one must be removed:

async function subscribeToUpdates(signal: AbortSignal) {
  const updates = [];
  const onAbort = () => console.log("aborting updates");
  signal.addEventListener("abort", onAbort);

  try {
    // ...listen for updates...
  } finally {
    signal.removeEventListener("abort", onAbort);
  }
}

Combining Signals with AbortSignal.any()

Often you have multiple reasons to cancel: a user timeout, a parent scope abort, a manual stop button. Use AbortSignal.any() to merge them:

const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(30000);
const mergedSignal = AbortSignal.any([userController.signal, timeoutSignal]);

await fetch(url, { signal: mergedSignal });

// Now the fetch cancels if either the user manually aborts
// or the 30s timeout fires—whichever comes first.

This is cleaner than nesting try-catch blocks or managing multiple signals independently.

Structured Concurrency: The Bigger Picture

JavaScript’s AbortController solves propagation, but it requires discipline. Python and Kotlin have structured concurrency built into the language:

Python (asyncio.TaskGroup, Python 3.11+):

async def fetch_user_with_posts(user_id: str):
    async with asyncio.TaskGroup() as tg:
        user_task = tg.create_task(fetch(f"/api/users/{user_id}"))
        posts_task = tg.create_task(fetch(f"/api/users/{user_id}/posts"))
    
    user = await user_task
    posts = await posts_task
    return {"user": user, "posts": posts}

When the TaskGroup context exits, all tasks are guaranteed to be cancelled and awaited. Cancellation is automatic; cleanup is guaranteed. If an exception occurs, all sibling tasks are cancelled.

Kotlin coroutines have a similar scope model: all launched tasks belong to the scope, and scope cancellation cascades to all children.

JavaScript lacks this structure, but we can approximate it by disciplining ourselves to:

  1. Create an AbortController at the scope boundary.
  2. Pass the signal to all child operations.
  3. Call abort() or ensure the controller is destroyed when the scope exits.
  4. Always use finally blocks for cleanup.

A pattern that mirrors structured concurrency:

class AsyncScope {
  private controller = new AbortController();

  get signal(): AbortSignal {
    return this.controller.signal;
  }

  cancel() {
    this.controller.abort();
  }
}

async function userModule(scope: AsyncScope) {
  const user = await fetch(`/api/users/1`, { signal: scope.signal });
  return user.json();
}

const scope = new AsyncScope();
try {
  const user = await userModule(scope);
} finally {
  scope.cancel(); // Ensures all operations tied to this scope are aborted.
}

This isn’t a language feature, but it enforces the pattern.

Detection and Testing

How do you know if your code leaks? A few signals:

  1. Dangling timers. Use chrome://devtools DevTools → Performance → record, stop, search for setTimeout/setInterval in the timeline. Long-running timers that should have fired indicate a leak.

  2. Network requests in flight. DevTools → Network, perform an action that should cancel requests (navigate away, unmount a component), refresh and check whether old requests still complete or error out. They should error with AbortError.

  3. Memory growth. Use the Heap profiler in DevTools. Navigate, cancel operations, navigate back. If memory doesn’t reclaim, a listener or a timer is holding it.

  4. Event listener count. Query getEventListeners(signal) in the console to see active listeners on a specific signal.

Test cancellation explicitly:

test("aborts fetch on timeout", async () => {
  const controller = new AbortController();
  const fetchPromise = fetch(url, { signal: controller.signal });

  setTimeout(() => controller.abort(), 100);

  await expect(fetchPromise).rejects.toThrow(DOMException);
  expect(controller.signal.aborted).toBe(true);
});

And test cleanup:

test("removes event listeners on cleanup", async () => {
  const signal = new AbortController().signal;
  const listener = jest.fn();

  signal.addEventListener("abort", listener);
  signal.dispatchEvent(new Event("abort"));

  expect(listener).toHaveBeenCalledTimes(1);
});

Key Takeaways

  • Use AbortController and AbortSignal to propagate cancellation through async chains; pass the signal down, keep the controller at the callsite.
  • Prefer AbortSignal.timeout(ms) over manual setTimeout for simple timeouts.
  • Always clean up listeners and timers in finally blocks; signals don’t automatically release attached listeners.
  • Use AbortSignal.any() to merge multiple cancellation sources (timeout, user action, parent scope).
  • Test cancellation paths explicitly; lean on DevTools Network and Heap profilers to detect leaks.
  • Think in terms of structured concurrency: each scope owns an AbortController, passes its signal to all children, and cancels on scope exit.
  • Understand that cancellation is not a suggestion—once aborted, the signal is .aborted permanently, and all operations checking it will fail fast.