Async work rarely fails at a convenient boundary. A request can lose its caller while waiting for a connection, expire after a remote side effect has begun, or complete at the same instant that a timeout fires. An API that merely races its promise against a timer may return promptly, yet leave the original work consuming sockets, memory, locks, or queue capacity in the background.

The central design rule is that time budget and cancellation are part of an operation’s contract, not decorations around a promise. A timeout limits how long one wait may last. A deadline records when the whole operation must be done. Cancellation communicates that the result is no longer wanted. Robust APIs preserve those distinctions, propagate one shrinking budget through every child operation, and make cleanup correct no matter which event wins a race.

This chapter develops that contract using AbortSignal-style TypeScript APIs. The mechanics apply to other runtimes as well: the names differ, but ownership, propagation, race handling, and cleanup remain the hard parts.

Treat the Budget as End-to-End State

A timeout is a duration, such as 500 milliseconds. A deadline is an instant, such as a monotonic clock reading at which work becomes too late. Durations are useful at an external boundary, but absolute deadlines compose better inside a call graph.

Suppose a handler receives a 900 ms budget. It spends 180 ms authenticating, 240 ms obtaining a database connection, and then calls a downstream service with a fresh 900 ms timeout. The nested call can now run until 1,320 ms after the original request began. Every layer appears to honor its timeout while the end-to-end contract is violated.

Instead, calculate one deadline at admission and derive remaining time at each boundary:

remaining=deadlinemonotonicNow()remaining = deadline - monotonicNow()

If remaining <= 0, do not start more work. Otherwise, a child may receive the same deadline or a deliberately smaller sub-deadline. This makes elapsed time visible and prevents each layer from silently resetting the budget.

Use a monotonic clock for elapsed-time decisions. Wall clocks can jump because of synchronization, manual adjustment, or virtualization. A wall-clock timestamp may still be useful in logs or a cross-process protocol, but a process should convert it carefully and use its monotonic source for local timers. Also remember that a deadline is not a precision guarantee: an event loop or thread may be busy, so expiration is observed at or after the requested instant.

A budget needs an owner. Usually the top-level request, job, or user action chooses it. Internal layers should not invent longer budgets, and they should not shorten one accidentally. A layer may reserve time for cleanup or a final response, but that policy should be explicit:

ts
type Deadline = Readonly<{ expiresAtMs: number }>;

function remainingMs(deadline: Deadline, nowMs: () => number): number {
  return Math.max(0, deadline.expiresAtMs - nowMs());
}

function childDeadline(
  parent: Deadline,
  maximumMs: number,
  nowMs: () => number,
): Deadline {
  return {
    expiresAtMs: Math.min(parent.expiresAtMs, nowMs() + maximumMs),
  };
}

The Math.min is the invariant: a child cannot outlive its parent budget. A child-specific cap can protect a later phase without granting extra time.

Make Cancellation an Explicit API Contract

An optional signal?: AbortSignal parameter is useful only when its behavior is defined. Callers need to know whether cancellation is observed before work begins, while waiting, during computation, and after a side effect becomes irrevocable. Implementers need to know who owns resources and which cleanup must finish before rejection becomes visible.

A practical contract answers five questions:

  1. What abort reason or error shape reaches the caller?
  2. At which safe points does the operation observe cancellation?
  3. Does rejection mean all owned resources have been released?
  4. Can a side effect have committed even though the caller receives an abort error?
  5. May a caller reuse supplied objects after cancellation returns?

Check an already-aborted signal synchronously or before the first resource acquisition. Otherwise, an operation can allocate a socket or enqueue work even though cancellation was known at entry. During long CPU work, cancellation is cooperative: code must periodically yield or check the signal. During I/O, pass the signal to the underlying API when it supports cancellation rather than merely abandoning the returned promise.

Cancellation is a request, not time travel. It cannot undo bytes already sent, a transaction already committed, or a message already acknowledged. An operation should identify its cancellation boundary. Before that boundary, it can stop with no externally visible effect. After it, the correct result may be “outcome unknown” or a normal success, depending on what can be established. Claiming that every aborted call had no effect is dangerous.

Prefer one signal that represents the combined reasons an operation should stop: caller cancellation, deadline expiration, or local shutdown. Preserve the first meaningful reason where the runtime permits it. Code should not infer business meaning from a generic string, but distinguishing DeadlineExceeded, explicit user cancellation, and shutdown improves diagnostics and response mapping.

Propagate Deadlines and Signals Through the Call Graph

Propagation must follow ownership. A parent starts child work, passes cancellation context into it, and waits for it to settle before the parent scope is considered finished. If a child spawns untracked work, cancellation at the parent becomes advisory and resource lifetime becomes invisible.

The following shape keeps the deadline and signal together without hiding either one:

ts
type AsyncContext = Readonly<{
  deadline: Deadline;
  signal: AbortSignal;
  nowMs: () => number;
}>;

interface ProfileStore {
  read(userId: string, context: AsyncContext): Promise<UserProfile | null>;
}

interface PermissionService {
  read(userId: string, context: AsyncContext): Promise<ReadonlySet<string>>;
}

Every async boundary accepts the context explicitly. That makes omission visible in review and tests. Ambient context can reduce parameter plumbing, but it also makes lifetime and test control less obvious; if a runtime-local context mechanism is used, wrappers should still expose a clear cancellation contract.

Parallel children normally share the parent deadline. If either failure makes the combined result unusable, abort the sibling and then await both settlements. Returning immediately after one rejects can leave the sibling running and can hide its cleanup failure.

Sequential phases need budget checks before each phase, not only one timer around the whole function. The outer timer limits the caller’s wait, while inner propagation gives connection acquisition, reads, and parsing a chance to stop their own work. Both matter. A non-cancellable dependency may still be bounded from the caller’s perspective, but it should be treated as detached resource consumption until it actually finishes.

Avoid converting cancellation into a fallback value deep in the stack. Returning an empty list after abort makes upstream code believe the operation completed. Cancellation should normally remain a distinct control outcome until a boundary intentionally maps it to a user response, retry decision, or partial result.

Worked Example: A Deadline-Aware Profile Load

Consider a profile endpoint that reads a user record and permissions concurrently. It must stop both reads when the caller disconnects or the deadline expires, release a temporary cache lease, and avoid leaking timer listeners.

ts
class DeadlineExceeded extends Error {
  constructor() {
    super('Operation deadline exceeded');
    this.name = 'DeadlineExceeded';
  }
}

function deadlineSignal(
  parent: AbortSignal,
  deadline: Deadline,
  nowMs: () => number,
): { signal: AbortSignal; dispose: () => void } {
  const controller = new AbortController();
  let timer: ReturnType<typeof setTimeout> | undefined;

  const abortFromParent = () => controller.abort(parent.reason);
  if (parent.aborted) {
    abortFromParent();
  } else {
    parent.addEventListener('abort', abortFromParent, { once: true });
  }

  const delay = deadline.expiresAtMs - nowMs();
  if (!controller.signal.aborted) {
    if (delay <= 0) controller.abort(new DeadlineExceeded());
    else timer = setTimeout(() => controller.abort(new DeadlineExceeded()), delay);
  }

  return {
    signal: controller.signal,
    dispose: () => {
      if (timer !== undefined) clearTimeout(timer);
      parent.removeEventListener('abort', abortFromParent);
    },
  };
}

type CacheLease = Readonly<{ release: () => Promise<void> }>;

async function loadProfile(input: {
  userId: string;
  context: AsyncContext;
  profiles: ProfileStore;
  permissions: PermissionService;
  acquireLease: (userId: string, signal: AbortSignal) => Promise<CacheLease>;
}): Promise<Readonly<{ profile: UserProfile; permissions: ReadonlySet<string> }>> {
  const scoped = deadlineSignal(
    input.context.signal,
    input.context.deadline,
    input.context.nowMs,
  );
  let lease: CacheLease | undefined;

  try {
    scoped.signal.throwIfAborted();
    lease = await input.acquireLease(input.userId, scoped.signal);
    scoped.signal.throwIfAborted();

    const childContext: AsyncContext = {
      ...input.context,
      signal: scoped.signal,
    };

    const [profile, permissions] = await Promise.all([
      input.profiles.read(input.userId, childContext),
      input.permissions.read(input.userId, childContext),
    ]);

    if (profile === null) throw new Error('Profile not found');
    return { profile, permissions };
  } finally {
    try {
      if (lease !== undefined) await lease.release();
    } finally {
      scoped.dispose();
    }
  }
}

The deadline helper has one terminal transition because AbortController.abort is idempotent. Parent cancellation and timer expiration may race, but only the first reason becomes the signal’s reason. dispose clears the timer and removes the parent listener on every path, including success.

The lease is released in finally, and the function does not reject until that release attempt settles. That creates a strong and useful postcondition: after loadProfile settles, it no longer owns the lease. Cleanup itself is not tied to the already-aborted signal, because using that signal could cancel the release immediately. In a real system, cleanup may need its own short, independently owned bound so it cannot hang forever.

Promise.all does not cancel sibling promises by itself. Correctness therefore depends on both stores honoring childContext.signal. If one rejects, the example’s shared controller is not automatically aborted by that rejection. A production combinator can abort siblings on first failure, then use Promise.allSettled to wait for their cleanup. Whether sibling failure should cancel the group is an operation-level policy, distinct from the deadline mechanism.

Make Cleanup Safe Under Every Race

Cancellation exposes ownership bugs that success paths conceal. A useful cleanup discipline is acquire, publish ownership, use, and release in a finally block. Set the local ownership variable immediately after acquisition succeeds. If cancellation arrives between acquisition and the assignment, the acquisition API must define whether it resolves with ownership or rejects without ownership; an ambiguous half-acquired state cannot be repaired by the caller.

Cleanup should be idempotent whenever practical. Event listeners can be removed twice, timers can be cleared after firing, and leases can carry a token that makes duplicate release harmless. Idempotence does not excuse unclear ownership, but it shrinks the damage from close races and layered cleanup.

Do not use the cancelled operation’s signal for mandatory local cleanup. Closing a file descriptor, returning a connection to a pool, or unregistering a listener is part of restoring process invariants. Remote compensation is different: it can fail, needs its own policy, and may not be able to undo a committed effect.

There is also a race between producing a result and observing cancellation. Choose a linearization rule. One reasonable contract is: if the result has been committed to the caller-facing promise first, return it even if abort follows; if abort is observed first, reject and discard any later result. Another API may prefer to check the signal immediately before return. Either can work, but tests and documentation must agree. “Whichever callback happens to run” is not a stable contract.

Be careful with cancellation listeners. Adding one listener per operation without removing it on success retains closures and may trigger listener warnings. Register with { once: true }, but still remove the listener when the operation finishes before cancellation. The signal may be long-lived, especially for process shutdown.

Avoid Promise-Race and Timer Failure Modes

The tempting timeout helper is Promise.race([operation, delayThenReject]). It bounds observation, not execution. The losing operation keeps running unless it receives and honors cancellation. Its later rejection can also become unobserved if the wrapper has not attached a handler correctly.

Timer-only wrappers have other failure modes:

  • Each nested layer grants a fresh duration and expands the end-to-end budget.
  • A timer remains scheduled after fast success, retaining state until it fires.
  • Timeout rejection happens before resource cleanup, so callers start replacement work while old work still owns capacity.
  • A timeout is reported even though an irreversible side effect committed concurrently.
  • The wrapper aborts shared work that other callers still need.

Shared work requires reference-aware ownership. If ten callers await one cached refresh and one caller cancels, cancelling the underlying refresh would fail the other nine. Each caller can stop awaiting independently; the underlying task should be aborted only when its owner decides it is no longer needed, perhaps when the final waiter detaches.

Conversely, silently detaching work can overload the process. Track operations that cannot be cancelled, limit how many may remain in flight, and expose their actual completion separately from caller-visible timeout counts. This is async resource accounting, not a reason to pretend the work stopped.

Error classification should preserve the causal reason. A caller abort, a deadline expiration, an I/O failure observed during cancellation, and cleanup failure are not interchangeable. If both the body and cleanup fail, retain both, for example with AggregateError or a primary error plus structured cause. Losing cleanup failure makes resource corruption difficult to diagnose.

Test the Races, Not Just the Happy Path

Time-based tests should not wait for real seconds. Inject a monotonic clock and use a controllable timer implementation so the test can advance directly to a boundary. Fake time alone is insufficient if promise continuations and I/O completions still run nondeterministically, so expose barriers or deferred promises at important race points.

At minimum, test these schedules:

Schedule Required observation
Signal aborted before entry No resource is acquired
Deadline already expired Immediate deadline error and no timer leak
Success before deadline Result returned; timer and listener removed
Parent abort while acquiring Acquisition stops or resolves into owned cleanup
Abort after acquisition Lease released before rejection settles
Child fails while sibling runs Sibling policy is applied and both settle
Completion and deadline at same instant Documented winner rule holds
Cleanup fails after cancellation Failure remains observable with the abort reason

A deferred test double can stop acquisition immediately before it resolves. The test aborts the signal, releases the barrier, and verifies exactly one release if ownership was transferred. A second barrier immediately before result publication tests the completion-versus-abort rule. Repeat both event orders; a race test that exercises only one order is merely an integration test with fortunate timing.

Assert negative properties too: active timer count returns to zero, abort listener count returns to baseline, no pool lease remains checked out, and no unhandled rejection is emitted after the test finishes. These postconditions catch leaks that checking only the returned error misses.

Property-based schedule tests can generate event orders under constraints such as “acquire resolves once” and “abort fires at most once.” Useful invariants include at-most-one release, no use before acquisition, no callback after settlement, and no child operation surviving parent settlement unless explicitly detached. Seeded stress can supplement these checks, but deterministic barriers should cover every known boundary.

Operate and Review Cancellation-Aware APIs

Instrument the operation lifecycle rather than only counting timeout responses. Record the initial budget, remaining budget at major child boundaries, cancellation reason, phase at cancellation, cleanup duration, and actual child completion. Avoid high-cardinality labels such as raw user IDs. A growing gap between caller-visible timeouts and underlying completions reveals work that is being abandoned rather than cancelled.

Useful operational signals include expired-before-start counts, cancellations by phase, resources still held after caller settlement, timer and listener counts, and cleanup failures. Long cleanup is itself a latency concern, but returning before cleanup changes the API postcondition; make that tradeoff explicit rather than accidental.

During review, trace one context from admission to the deepest I/O call. Confirm that no layer resets the deadline, every cancellable dependency receives the signal, every acquired resource has one owner, and every terminal path removes listeners and timers. Mark the point after which cancellation cannot promise “no effect.” Then inspect simultaneous completion, abort, and cleanup failure as first-class paths.

Timeouts protect a wait. Deadlines preserve a total budget. Cancellation tells owned work to stop. None of them automatically reclaims resources or reverses effects. Async APIs become dependable when those mechanisms share one lifetime model: an owner sets the budget, children inherit it, cancellation is observed at defined boundaries, and settlement means the promised cleanup postconditions are true.