← JavaScript: language and application diagnosis
07 / 10 · 55 MIN

Promises, async and error propagation

Connect promises, dependencies and outcomes; identify work escaping the chain and distinguish deadlines from cancellation.

Future values and local flow

An async function returns a promise on every call. A normal return supplies the value used to resolve that promise; an uncaught exception rejects it. Even async function count() { return 4; } returns a promise although its body contains no await. Distinguish the future value from the promise itself.

The body starts executing when the function is called and proceeds until it suspends at an await or finishes. Await permits resumption after a dependency produces an outcome without blocking the entire host. Rejection resumes as a throw at that point. This dependency model does not guarantee that CPU-intensive work automatically moves to another thread. An async function can still perform lengthy synchronous work before its first suspension.

Return connects work to the chain

Then creates a derived promise. If its handler returns an ordinary value, that value resolves the derived promise; if it throws, the derived promise rejects; if it returns a promise, the derived promise tracks that dependency outcome. A rejection handler can also recover: returning a normal value makes the next step successful. Decide whether that recovery respects the contract instead of always returning an empty list.

In start().then(() => { save(); }).then(showDone), the first handler returns undefined. save was started, but its promise is absent from the handler return value. showDone can execute before save finishes. Returning save() establishes the dependency. Analysis should trace each started operation to the caller observing its outcome, including rejection paths.

Dependencies, concurrency and bounds

If two reads first need a plan, await the plan before starting them. If they are then independent, they can start together. Promise.all preserves each input position in its success array regardless of operation completion order. A rejection rejects the aggregate but does not automatically cancel other operations.

forEach calls callbacks and discards their return values. await ids.forEach(async id => save(id)) awaits the undefined returned by forEach rather than the saves. For a small set where concurrency is allowed, map collects promises and Promise.all tracks joint success. For large lists, starting everything at once can exceed service capacity: the application needs a limit on in-flight operations. Aggregation is not concurrency limiting.

Joint success, individual outcomes and deadline

Choose the combinator from the required outcome. Promise.all serves an operation needing every success. Promise.allSettled supports a report with one record per input, distinguishing fulfilled status with value from rejected status with reason. With an ordinary promise array it waits for every input and preserves input order. This does not mean every possible allSettled call is immune to setup or iteration errors.

Promise.race determines its promise through the first input that resolves or rejects it. If that input represents a deadline, you only know that the aggregate wait ended. Remaining work can continue. Do not infer cancellation, absence of effects or permission to repeat a write. Repetition requires an identity and reconciliation rule supplied by the application; the promise library does not create that rule.

Catch, await and cleanup

A try catches throws occurring within the evaluation it surrounds. Directly returning a rejected promise inside try does not make the local catch observe its reason: the function returns an asynchronous outcome that the caller receives as rejection. Returning await of that promise lets rejection resume as a throw inside try. Use this distinction when the function has explicit responsibility for translating or recovering the failure.

The finally callback receives no arguments. If it completes normally, it preserves the earlier value or reason even when it returns a different ordinary value. If it throws or returns a rejected promise, cleanup can replace the earlier outcome with a new rejection. Cleanup that can fail needs a clear policy for retaining context; finally does not guarantee that the original failure is always what the caller observes.

Guided example: prepare an estimate

In buildEstimate, first mark the boundaries: readPlan supplies the ID; readUnits and readRate use that ID and can coexist; the estimate is created only after both succeed and pass the bounds. With plan.id P7, units 3 and rate 8, the final object contains planId P7 and total 24. If rate is the string "8", validation rejects; there is no implicit conversion.

If readPlan rejects, the next two reads do not start. If readRate rejects after both start, buildEstimate rejects without returning an estimate, but readUnits can continue. Integer bounds from 0 to 1000 keep the product within a small exact range. The code neither publishes to an interface nor writes to a service; the caller must await its outcome and handle rejection before deciding what to display.

Diagnose order without inventing guarantees

A then reaction is scheduled even when the promise is already fulfilled. In a sequence that inserts A, registers a then to insert B and synchronously inserts C, the final order is A, C, B. This prediction depends on the sequence and later reaction execution, not on a fixed number of milliseconds. Do not extrapolate a universal ordering among timers, network activity and browser painting.

When reviewing a chain, ask what work each promise represents, where its error is observed and whether effects can survive rejection. In questions, distinguish a wrong value from a missing dependency. In cases, choose an order respecting prerequisite data and publication policy. Assessment uses reading and selection among alternatives; running the excerpts is not required to obtain the answer.

async function buildEstimate(readPlan, readUnits, readRate) {
  const plan = await readPlan();
  if (plan === null || typeof plan !== "object" || Array.isArray(plan) ||
      typeof plan.id !== "string" || plan.id.length === 0) {
    throw new TypeError("plan");
  }
  const [units, rate] = await Promise.all([
    readUnits(plan.id),
    readRate(plan.id)
  ]);
  if (!Number.isInteger(units) || units < 0 || units > 1000 ||
      !Number.isInteger(rate) || rate < 0 || rate > 1000) {
    throw new TypeError("estimate inputs");
  }
  return { planId: plan.id, total: units * rate };
}
IN PRACTICE

Read the example as a contract without executing it. The three supplied functions return standard promises and represent reads; the plan is an ordinary, stable record with no getters or concurrent mutation. readUnits and readRate can start only after receiving the plan ID and are independent of one another.

Common pitfalls

Omitting return for work started in then; treating await forEach as aggregation; confusing input order with completion order; expecting race or all to undo work.

Related topics: Bindings, functions and closures · Errors, validation and failure contracts · Browser events, HTTP and cancellation · State, ordering and integration diagnosis

Take this idea with you

A promise should represent all work the caller needs to track. Choosing all, allSettled or race depends on the outcome contract.

Create account

Reference: Promise.prototype.then · ECMAScript 2026 (17th edition); browser APIs identified separately