JavaScript Async/Await: Concurrency, Cancellation and Reliable Errors

Signal Gull coordinates sequential and concurrent asynchronous tasks with explicit cancellation, success, partial failure and error states.

Written by

in

async and await make Promise-based code read in a structured sequence. They do not make network requests synchronous, create threads or decide whether work should run sequentially or concurrently. Those design choices remain visible in when Promises are created, how they are combined and what happens when one fails.

An async function always returns a Promise. A returned value fulfils it; an uncaught throw or rejected awaited Promise rejects it. await pauses execution of the surrounding async function until the awaited value settles, while other JavaScript work can continue. It does not block the browser's main thread by itself (MDN — async function; MDN — await).

Article map for JavaScript Async/Await: Concurrency, Cancellation and Reliable Errors, covering Keep the Promise contract visible, Decide whether operations are sequential or concurrent, Preserve useful error context an…
Article map: Keep the Promise contract visible; Decide whether operations are sequential or concurrent; Preserve useful error context; Cancellation is part of correctness.

Keep the Promise contract visible

async function loadCustomer(id) {
  if (!id) {
    throw new TypeError('A customer ID is required');
  }

  const response = await fetch(`/api/customers/${encodeURIComponent(id)}`);
  if (!response.ok) {
    throw new Error(`Customer request failed: ${response.status}`);
  }

  return response.json();
}

Calling loadCustomer() returns a Promise immediately. The caller must await it, return it or attach rejection handling. Simply invoking an async function in an event handler and ignoring its result can create an unhandled rejection and a UI that never leaves its loading state.

Fetch resolves its Promise when an HTTP response is available even for statuses such as 404 or 500. Check response.ok or the expected status explicitly. A network error and an application rejection are different operational conditions and may deserve different user messages and retry policies.

Decide whether operations are sequential or concurrent

This code is sequential because the second request is not started until the first completes:

const profile = await fetchProfile();
const preferences = await fetchPreferences();

If the calls are independent, start both and await them together:

const [profile, preferences] = await Promise.all([
  fetchProfile(),
  fetchPreferences(),
]);

Promise.all() rejects as soon as one input rejects, but the other underlying operations continue unless they support cancellation. Use it when all results are required and one failure makes the combined result unusable.

Promise.allSettled() waits for every input and reports each fulfilment or rejection. It is useful when partial results are meaningful, such as loading independent dashboard panels. Promise.any() fulfils with the first successful input and rejects with an aggregate error if all reject. Promise.race() settles with the first settlement, whether fulfilment or rejection.

Do not use concurrency merely because it is shorter code. Starting hundreds of requests at once can overload a browser, API or database. Bound concurrency for large collections and respect service limits.

Preserve useful error context

Catch errors where the code can add context, translate them into a supported result or perform cleanup. Avoid catching an error only to log it and continue with invalid state.

async function showCustomer(id) {
  view.setLoading(true);

  try {
    const customer = await loadCustomer(id);
    view.renderCustomer(customer);
  } catch (error) {
    view.renderError('We could not load this customer. Please try again.');
    reportError(error, { operation: 'load-customer', customerId: id });
  } finally {
    view.setLoading(false);
  }
}

The user-facing message should be safe and actionable. Observability can record technical context, but avoid tokens, passwords, complete request bodies or unnecessary personal information. Preserve the original cause when wrapping an error where the runtime supports it:

throw new Error('Unable to load the account summary', { cause: error });

At an application boundary, handle otherwise unobserved failures and fail safely. A global rejection handler is a last-resort signal, not a substitute for local ownership.

Cancellation is part of correctness

When a user starts a new search, navigates away or closes a component, the old request may no longer be useful. Without cancellation or stale-result checks, an older response can overwrite newer state.

Fetch accepts an AbortSignal:

async function loadJson(url, { signal } = {}) {
  const response = await fetch(url, { signal });

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

  return response.json();
}

const controller = new AbortController();
const pending = loadJson('/api/report', { signal: controller.signal });

// Later, when the result is no longer needed:
controller.abort('The view changed');

Aborting rejects the fetch with an abort-related error. Treat an intentional cancellation differently from a failed request so the interface does not show a frightening error when the user simply changed pages. Pass one signal through all cancellable operations belonging to the task.

Promise.race() with a timer can stop waiting, but it does not cancel the losing operation. Use AbortController, an API-specific cancellation mechanism or both. The DOM Standard defines AbortController and AbortSignal as a shared cancellation model (WHATWG DOM — Aborting ongoing activities).

Decision path for JavaScript Async/Await: Concurrency, Cancellation and Reliable Errors, covering Preserve useful error context, Cancellation is part of correctness, Give operations an explicit time budget and related r…
Decision path: Preserve useful error context; Cancellation is part of correctness; Give operations an explicit time budget; Retry only when the operation and failure permit it.

Give operations an explicit time budget

A timeout should end or abandon work and release associated resources where possible. Modern runtimes may provide AbortSignal.timeout(), but support and server-side effects must be checked for the target environment.

const signal = AbortSignal.timeout(8_000);
const data = await loadJson('/api/report', { signal });

A client timeout does not prove the server stopped. It may have completed a write after the client gave up. This matters for payments, provisioning and other non-idempotent actions.

Retry only when the operation and failure permit it

Retries can recover from a transient network failure or a service response that explicitly asks the client to wait. They can also duplicate a charge, amplify an outage and conceal persistent defects.

Before retrying, define:

  • whether the operation is safe or idempotent;
  • an idempotency key for retryable creation where the server supports it;
  • which network or response conditions are transient;
  • maximum attempts and total time budget;
  • exponential backoff and jitter;
  • server-provided Retry-After; and
  • cancellation while waiting.

Do not automatically retry authentication failure, validation errors or arbitrary 500 responses without a specific policy. Surface the final failure and preserve correlation identifiers.

Represent loading, empty, partial and stale states

An asynchronous interface needs more than data or error. Model:

  • idle;
  • loading with any previous result intentionally retained or cleared;
  • success with data;
  • success with an empty result;
  • partial success where supported;
  • recoverable and unrecoverable failure; and
  • cancelled or superseded work.

Disable only controls that genuinely cannot be used, provide accessible status updates and preserve focus. Avoid infinite spinners. If optimistic UI is used, show pending state and define rollback or reconciliation when the server rejects the action.

Avoid common async traps

  • array.forEach(async item => ...) does not wait for callbacks. Use for...of for sequential work or map to Promises and combine them.
  • An async Promise executor is usually a design smell because the constructor does not handle its returned Promise as expected.
  • Top-level await can delay dependent module evaluation; use it deliberately rather than hiding application startup behind a slow request.
  • A finally block that returns a value can override a prior result or error.
  • Starting a Promise long before attaching a handler can produce an unhandled rejection window.
  • A resolved Promise callback still runs in a later microtask; it is not same-stack synchronous code.

MDN's Promise guide documents the main composition methods and notes that Promise.all() wires concurrent inputs into one rejection path, while Promise.allSettled() retains every outcome (MDN — Using Promises).

Control and evidence map for JavaScript Async/Await: Concurrency, Cancellation and Reliable Errors, covering Retry only when the operation and failure permit it, Represent loading, empty, partial and stale states, Avoid…
Control and evidence map: Retry only when the operation and failure permit it; Represent loading, empty, partial and stale states; Avoid common async traps; Test timing and failure, not only the happy path.

Test timing and failure, not only the happy path

Use controlled fakes for time and network boundaries. Test fast and slow success, each expected status, malformed data, timeout, cancellation, out-of-order responses, partial failure and retry exhaustion. Ensure cleanup runs and no state update occurs after a component is disposed.

Browser integration tests should verify visible loading, success and error states using observable conditions rather than fixed sleeps. Production monitoring should distinguish client cancellation, network failure, server error and validation rejection.

For help implementing or reviewing a browser or server JavaScript workflow, see Ozlin Info's web development services or contact Ozlin Info.

Related reading: HTTP API design: stable semantics, useful errors and safe change.


General-information disclaimer

This article provides general technical information only. Runtime support, cancellation behaviour and retry safety depend on the actual browser, server, API and operation.

AI-assistance disclosure

AI tools assisted with source discovery, outlining and copyediting. A human reviewer must run and verify every code example, compatibility statement, service claim and publication decision before release. No reliability or performance outcome is guaranteed.

Practical checklist for JavaScript Async/Await: Concurrency, Cancellation and Reliable Errors, covering Test timing and failure, not only the happy path, General-information disclaimer, AI-assistance disclosure and rela…
Practical checklist: Test timing and failure, not only the happy path; General-information disclaimer; AI-assistance disclosure; Primary sources checked.

Primary sources checked

Source access date: 29 August 2026.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *


This site uses Akismet to reduce spam. Learn how your comment data is processed.