Create your own
Lesson illustration

Coordinating and Cancelling Concurrent Async Operations

Hello again. In the previous lesson, you separated synchronous execution, Node’s priority queues, and event-loop phases. That model explains when callbacks get a chance to run. This lesson focuses on a related production decision: once several asynchronous operations are in flight, which outcomes are required, which failures can be tolerated, and how does work stop when the result is no longer useful?

For a backend endpoint, starting several independent reads at once can lower latency. But promises alone do not provide cancellation. You need to choose a Promise combinator that matches the endpoint’s success policy and pair it with AbortSignal when unfinished work should stop.

Plan for about 40 minutes: compare the four combinators, implement a coordinated fan-out request, and learn a cancellation contract suitable for Node.js services.


1. Promise combinators express an outcome policy

A promise represents work that is already under way. This is fundamental: passing promises to Promise.all() does not start them. The operations began when you called the functions that created those promises.

const projectPromise = getProject(projectId);
const membersPromise = listProjectMembers(projectId);

// Both operations have already been started.
const [project, members] = await Promise.all([
  projectPromise,
  membersPromise,
]);

The combinator decides how to combine their outcomes; it does not control the lifetime of the underlying work.

Read MDN’s concise comparison before looking at production patterns.

Promise - JavaScript - MDN Web Docs

Read the “Promise concurrency” section in MDN Web Docs. It gives the formal fulfillment and rejection rules for all four combinators, then makes the essential distinction: Promises themselves have no built-in cancellation protocol.

In the “Promise concurrency” subsection, read the comparison of Promise.all, Promise.allSettled, Promise.any, and Promise.race. Then, in the Description section immediately before “Chained Promises,” find the note beginning “Promise itself has no first-class protocol for cancellation” and connect it to the cancellation patterns later in this lesson.

Here is the decision table to keep in mind:

A comparison of when `Promise.all()`, `Promise.race()`, `Promise.allSettled()`, and `Promise.any()` fulfill or reject. The table describes the combined promise’s outcome, not whether the remaining operations are cancelled.
CombinatorIt fulfills when…It rejects when…Typical backend use
Promise.all()Every input fulfillsAny input rejectsEvery result is required
Promise.allSettled()Every input settlesNever, except unusual setup errorsPartial results or batch reporting
Promise.any()At least one input fulfillsEvery input rejects, with AggregateErrorRedundant read from interchangeable providers
Promise.race()The first input fulfillsThe first input rejectsObserve the first settled outcome

Promise.all(): all results are required

Use Promise.all() when a response is invalid or incomplete without every dependency. It fails fast from the caller’s perspective: as soon as one promise rejects, the combined promise rejects.

const [project, members, permissions] = await Promise.all([
  projectRepository.getById(projectId),
  memberRepository.listByProjectId(projectId),
  permissionService.forUser(userId, projectId),
]);

Two details matter in senior-level code review:

  1. Result order follows input order, not completion order. If permissions return first, they still occupy index 2 in the result array.
  2. A rejection does not cancel its siblings. A slow memberRepository call continues unless the underlying operation receives an explicit cancellation signal.

That second property is the source of many avoidable production issues. An endpoint may send a 500 response after one dependency fails while several unnecessary downstream requests, queries, or body streams continue consuming resources.

Promise.allSettled(): every outcome is useful

Use Promise.allSettled() when partial success is meaningful and you deliberately define how to degrade.

For example, imagine a project overview page where the project record is required, but activity suggestions and usage analytics are optional. Do not use allSettled() merely to avoid handling errors; use it because the product can still provide useful behavior.

const outcomes = await Promise.allSettled([
  activityService.getRecent(projectId),
  analyticsService.getUsageSummary(projectId),
]);

const [activityOutcome, usageOutcome] = outcomes;

const activity =
  activityOutcome.status === 'fulfilled'
    ? activityOutcome.value
    : [];

const usage =
  usageOutcome.status === 'fulfilled'
    ? usageOutcome.value
    : null;

Each result is a discriminated union:

  • { status: 'fulfilled', value: ... }
  • { status: 'rejected', reason: ... }

The status check narrows the TypeScript type correctly. More importantly, it forces an explicit policy: perhaps return an empty activity feed, omit a nonessential chart, and log the rejected dependency with enough context to investigate.

allSettled() is especially useful for batch jobs, cleanup tasks, or notification fan-out, where one failure should be recorded but should not prevent collecting all outcomes.

Promise.any(): the first successful result wins

Promise.any() is suited to interchangeable sources. Suppose an avatar is available from two independent image providers, and either valid response is acceptable:

const avatar = await Promise.any([
  avatarProviderA.get(userId),
  avatarProviderB.get(userId),
]);

Unlike Promise.race(), a fast failure does not end the operation. Promise.any() keeps waiting for a fulfillment. It rejects only if every candidate rejects, and that rejection is an AggregateError containing all reasons.

This makes it a good fit for redundancy, not for “try provider A, then provider B” sequencing. If you must prefer one provider and only use another after a failure, write that policy explicitly with try/catch; do not start both requests unnecessarily.

Promise.race(): first settlement, whether success or failure

Promise.race() settles with whichever input settles first. That is occasionally exactly what you mean, but it is easy to use incorrectly for timeouts.

This pattern is incomplete:

await Promise.race([
  fetch('https://dependency.example/api/report'),
  rejectAfter(1_500),
]);

If rejectAfter wins, your code stops waiting, but the fetch may continue in the background. The race changed the result you observe; it did not tell fetch to stop.

For cancellation-capable APIs such as Node’s built-in fetch, use an abort signal for a deadline instead:

const response = await fetch('https://dependency.example/api/report', {
  signal: AbortSignal.timeout(1_500),
});

The timeout signal makes the request abortable rather than merely making your caller impatient.


2. AbortController defines an operation’s lifetime

An AbortController owns cancellation. It exposes an AbortSignal, which you pass to APIs participating in the operation. Calling controller.abort(reason) marks the signal as aborted and notifies each listener.

The key word is cooperative. JavaScript does not forcibly terminate arbitrary code. Cancellation works only when an API accepts and obeys a signal, or when you write your own operation to observe it.

Read the Node documentation for the precise API surface.

Global objects | Node.js v26.7.0 Documentation

Read Node’s reference for AbortController and AbortSignal. Focus on the one-way nature of abortion, reasons, deadlines, composition, and listener cleanup.

In “Global objects,” read the subsections “Class: AbortController” and “Class: AbortSignal,” beginning with the controller and signal basics. In the AbortSignal reference, also read the static methods AbortSignal.timeout() and AbortSignal.any(), then continue through abortSignal.reason and abortSignal.throwIfAborted(). Finally, in the “‘abort’ event” subsection, note the listener-cleanup guidance.

The practical rules are:

  • Create one controller per logical operation. A controller cannot be reset after aborting.
  • Pass its signal, not the controller, into lower-level functions.
  • Use abort(reason) with a meaningful reason where possible.
  • If you attach an 'abort' listener yourself, use { once: true } and remove any listener when the operation completes normally.
  • Check signal.aborted before beginning work that may already be obsolete.
  • Treat cancellation separately from an ordinary dependency failure in logs and metrics.

A useful mental model is that a signal flows downward through the call graph:

request-level controller
  service operation receives signal
    repository or HTTP client receives signal
      cancellation-capable API observes signal

The controller stays with the component that owns the lifetime decision. A repository should not normally abort a signal it was given; it may observe it, but its caller decides when the broader operation is no longer needed.

For a quick visual demonstration of the API, watch these selected parts of Web Dev Simplified’s video.

I Cannot Believe Abort Controller Can Do This

Web Dev Simplified’s “I Cannot Believe Abort Controller Can Do This” demonstrates the basic controller-signal relationship, then shows the built-in timeout and signal-composition helpers used in Node services.

Watch basic fetch cancellation to see a signal passed to fetch. Then watch timeout and composition, focusing on AbortSignal.timeout() and AbortSignal.any(). The event-listener examples are browser-oriented; carry the signal-lifetime idea into backend code rather than copying the UI-specific setup.


3. Coordinating a required fan-out request

Consider a project-management API endpoint that needs a project, its members, and recent activity. All three are required to build the endpoint’s response. They can run concurrently, but once any one fails, keeping the others alive is wasteful.

type DashboardData = {
  project: Project;
  members: Member[];
  activity: ActivityEvent[];
};

type LoadOptions = {
  signal?: AbortSignal;
};

async function loadProjectDashboard(
  projectId: string,
  options: LoadOptions = {},
): Promise<DashboardData> {
  const operation = new AbortController();

  const signal = AbortSignal.any([
    operation.signal,
    AbortSignal.timeout(1_500),
    ...(options.signal ? [options.signal] : []),
  ]);

  try {
    const [project, members, activity] = await Promise.all([
      projectService.getById(projectId, { signal }),
      memberService.listByProjectId(projectId, { signal }),
      activityService.listRecent(projectId, { signal }),
    ]);

    return { project, members, activity };
  } catch (error) {
    // A dependency failed, so tell remaining cooperative work to stop.
    operation.abort(error);
    throw error;
  }
}

This design combines three independent cancellation causes:

  • The local operation controller aborts siblings when one required operation fails.
  • AbortSignal.timeout(1_500) imposes a service-level deadline.
  • options.signal allows the caller to cancel, for example because an HTTP client disconnected or a higher-level request deadline expired.

AbortSignal.any() creates one combined signal. It aborts when any constituent signal aborts, retaining the reason from the signal that caused it. Every downstream function receives that same combined signal.

Why Promise.all() alone is insufficient

Without the catch block and local controller, a failure in projectService.getById() makes Promise.all() reject, but memberService and activityService remain active. With the controller, the failure becomes a cancellation decision for the rest of the group.

There are still limits:

  • If activityService ignores signal, it will continue.
  • If an operation already completed, abortion has no effect on that completed result.
  • If the calls perform writes, cancellation does not automatically undo partial side effects. Correct write workflows need explicit transactional, idempotency, or compensation strategies.

So, treat cancellation as resource and relevance control, not as a general rollback mechanism.

Designing signal-aware functions

At each boundary, accept an optional signal and pass it to a native or library API that supports one.

async function getUserProfile(
  userId: string,
  options: LoadOptions = {},
): Promise<UserProfile> {
  const response = await fetch(
    `https://profiles.internal/users/${encodeURIComponent(userId)}`,
    { signal: options.signal },
  );

  if (!response.ok) {
    throw new Error(`Profile service returned ${response.status}`);
  }

  return response.json() as Promise<UserProfile>;
}

For your own delay-like or polling utility, you must handle the signal explicitly and clean up correctly:

export function delay(
  milliseconds: number,
  signal?: AbortSignal,
): Promise<void> {
  return new Promise((resolve, reject) => {
    const abort = () => {
      clearTimeout(timer);
      reject(signal?.reason ?? new Error('Operation aborted'));
    };

    if (signal?.aborted) {
      abort();
      return;
    }

    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', abort);
      resolve();
    }, milliseconds);

    signal?.addEventListener('abort', abort, { once: true });
  });
}

This utility handles both important cases:

  • A pre-aborted signal prevents waiting at all.
  • An abort during the delay clears the timer and rejects the promise.

The cleanup on normal completion matters. Long-running services can accumulate listeners if every operation attaches an abort handler and never removes it.


4. Cancelling losers in a Promise.any() fallback

Promise.any() has the opposite policy to Promise.all(): one success is sufficient. After a successful result, though, the losing operations may still be in flight. If those operations are expensive, cancel them in a finally block.

async function loadAvatar(
  userId: string,
  outerSignal?: AbortSignal,
): Promise<Avatar> {
  const winnerController = new AbortController();

  const signal = AbortSignal.any([
    winnerController.signal,
    ...(outerSignal ? [outerSignal] : []),
    AbortSignal.timeout(800),
  ]);

  try {
    return await Promise.any([
      avatarProviderA.get(userId, { signal }),
      avatarProviderB.get(userId, { signal }),
    ]);
  } finally {
    // Once there is a winner, or no winner is possible, stop remaining work.
    winnerController.abort(new Error('Avatar selection completed'));
  }
}

When provider A succeeds, Promise.any() fulfills. The finally block then aborts provider B if it is still running. If both providers fail, Promise.any() rejects with AggregateError, and the same cleanup runs.

This is a good pattern only when the requests are truly interchangeable and safe to start concurrently. For non-idempotent commands such as “charge a card” or “create an invoice,” racing two providers would be dangerous. A shared AbortSignal reduces unnecessary work; it does not guarantee that a remote side effect never occurred.


5. A selection checklist for production code

When you see concurrent promises in a Node service, make the policy visible in the code:

  1. Choose Promise.all() when every outcome is necessary.
  2. Choose Promise.allSettled() when partial outcomes have a defined product behavior.
  3. Choose Promise.any() only when any one successful source is equivalent.
  4. Treat Promise.race() as a first-settlement primitive, not a cancellation mechanism.
  5. Pass a shared AbortSignal to operations that should share a deadline or lifetime.
  6. When one required concurrent operation fails, abort cooperative siblings.
  7. Do not assume cancellation rolls back writes or stops libraries that do not support signals.
  8. Avoid unbounded Promise.all(items.map(...)) against a large collection; it starts all operations immediately and can overwhelm a database or remote dependency.

The last point is important in backend work. Promise combinators coordinate the promises you have already chosen to start; they are not a concurrency limiter. When you need bounded parallelism for a large batch, introduce an explicit worker-pool or queue policy rather than launching thousands of operations at once.


Key takeaways

Promise combinators express different success and failure contracts:

  • Promise.all() requires every result, but does not cancel siblings after failure.
  • Promise.allSettled() preserves every success and failure for deliberate partial-result handling.
  • Promise.any() returns the first success and rejects only when all candidates fail.
  • Promise.race() observes the first settlement, whether successful or not.

AbortController fills the cancellation gap. Create a controller for a logical operation, compose its signal with caller cancellation and deadlines when needed, and pass that signal to every cooperative dependency. For a required parallel fan-out, abort the group when one dependency fails; for a first-success strategy, abort losing requests once a winner exists.

Next, you will move from runtime coordination to safe startup behavior by implementing a typed configuration layer that validates environment variables before the Node.js service begins accepting work.

Can't find a good explanation? Sign up and we'll make it for you

Sign up