New54 new lessons added since Sep 10!
Explore What's New →
Software Development Atlas
ProgrammingAsynchronous Programming

Promises: Resolution, Chaining, and Failure

Reason about Promise states, resolution, chaining, error recovery, adoption, combinators, and modern Promise APIs.

EvolvingVerified Sep 10, 2026Review target: 180 days

Personal learning atlas by Tran Trong Thuc · About this Atlas · Atlas last updated Sep 22, 2026

TL;DR

A Promise is not the background operation running on the runtime thread—it is a claim ticket (like an order buzzer handed to you at a counter) representing an eventual outcome. Conflating the ticket with the background engine or misunderstanding how chain handlers shape downstream Promises causes catastrophic production outages. In one notable incident, an error handler inside a .catch() block merely logged a declined credit card and returned cleanly (undefined), silently fulfilling the downstream Promise and prompting the warehouse to dispatch goods to a customer whose payment had completely failed.

💡 Rule of thumb: Every call to .then(), .catch(), or .finally() creates a brand-new downstream Promise whose fate is determined solely by what its handler returns or throws. When diagnosing an async pipeline, always trace forward through downstream Promises rather than assuming the source Promise mutates.

  • A Promise is a receipt for a future value, not the running task: Rejecting or abandoning a Promise does not cancel in-flight network sockets, database queries, or OS threads.
  • Resolved does not mean fulfilled: A Promise is resolved when its eventual fate is locked. It can be pending yet resolved if it has adopted another still-pending Promise.
  • Chain methods allocate separate downstream Promises: Returning a plain value fulfills downstream; throwing rejects downstream; returning a Promise/thenable adopts that eventual outcome.
  • Combinators enforce strict contracts: Choose Promise.all() for fail-fast dependencies, Promise.allSettled() for complete observation, Promise.any() for first successful result, and Promise.race() for raw settlement timers.
  • Fatal pitfall (Swallowed Errors): An error-handling .catch() that merely logs an exception without rethrowing returns undefined, which silently fulfills the downstream Promise. Downstream .then() steps—such as order fulfillment or database commits—will execute as if the payment succeeded.

Start with one small chain

const p0 = Promise.resolve(10);
const p1 = p0.then((value) => value * 2);

Do not imagine p0 changing from 10 into 20. p0 stays fulfilled with 10; then() creates p1, and the handler's return value makes p1 fulfill with 20.

P0 fulfilled: 10
  ↓ handler returns 20
P1 fulfilled: 20

Promise state and resolution

The basic states are straightforward:

  • pending — neither fulfilled nor rejected yet;
  • fulfilled — completed successfully with a value;
  • rejected — completed unsuccessfully with a reason.
Promise state and resolution are different concepts

Promise state

pending
fulfilled(value)
rejected(reason)

Resolution

resolve(value)
May fulfill directly
resolve(otherPromise)
Adopt its eventual outcome
Resolved can still be pending
A Promise may be resolved to another pending Promise while its visible state is still pending.

The simple cases look unsurprising:

Promise.resolve(42);          // resolved and fulfilled
Promise.reject(new Error());  // resolved and rejected

The important case is adoption.

First, notice an identity rule that is easy to miss:

let resolveInner;

const inner = new Promise((resolve) => {
  resolveInner = resolve;
});

const same = Promise.resolve(inner);
console.log(same === inner); // true

For a native Promise produced by the same Promise constructor, Promise.resolve(inner) returns inner itself. It does not create a second wrapper Promise.

To demonstrate a distinct Promise adopting inner, construct a separate Promise and resolve it with inner:

const outer = new Promise((resolve) => {
  resolve(inner);
});

console.log(outer === inner); // false

While inner is still pending:

inner: pending
outer: pending + resolved/adopting inner

outer is already resolved, but it is not fulfilled yet because inner has not settled. Later:

resolveInner(42);

produces:

inner: fulfilled with 42
outer: fulfilled with 42

This is why “resolved” and “fulfilled” are not synonyms.

Promise construction: synchronous executor, asynchronous reactions

The executor passed to new Promise(...) runs synchronously during construction:

const events = [];

new Promise((resolve) => {
  events.push('executor');
  resolve();
});

events.push('after constructor');

console.log(events);
// ['executor', 'after constructor']

If the executor throws before either resolving function has already taken effect, the constructor attempts to reject the Promise with that thrown reason. But the first successful call to resolve or reject fixes the Promise's fate for subsequent resolving-function calls.

That distinction matters for a resolved-but-still-pending Promise:

let resolveInner;

const inner = new Promise((resolve) => {
  resolveInner = resolve;
});

const outer = new Promise((resolve) => {
  resolve(inner);
  throw new Error('does not replace the adopted outcome');
});

resolve(inner) has already resolved outer to inner. The later constructor attempt to reject outer after the throw has no effect because the resolving functions share a first-resolution guard. If inner later fulfills with 42, outer still fulfills with 42.

The constructor is useful when adapting callback-style APIs:

function readLegacyResource() {
  return new Promise((resolve, reject) => {
    callbackStyleApi((error, value) => {
      if (error) {
        reject(error);
        return;
      }

      resolve(value);
    });
  });
}

Do not wrap an API that already returns a Promise unless you need different lifecycle semantics. Prefer return fetch('/api/user') over constructing another Promise around fetch() merely to forward resolve and reject.

Promise reaction handlers do not run inline

console.log('A');

Promise.resolve().then(() => {
  console.log('promise handler');
});

console.log('B');

For a browser, the output is:

A
B
promise handler

For the browser scheduling mechanism that places Promise reactions into microtask processing, see Browser Event Loop: How Tasks, Microtasks, and Rendering Are Scheduled.

Every chain method creates a downstream Promise

With standard Promise methods, this:

const p1 = p0.then(handleValue);
const p2 = p1.catch(handleError);
const p3 = p2.finally(cleanup);

is better pictured as:

P0 --then(handleValue)--> P1 --catch(handleError)--> P2 --finally(cleanup)--> P3

P0, P1, P2, and P3 are separate Promise objects. Each downstream Promise has its own outcome.

Every Promise handler creates a downstream Promise
  1. p0
    Source Promise
  2. then(handler)
    Creates p1
  3. return value
    p1 fulfills
  4. throw error
    p1 rejects
  5. return Promise
    p1 adopts it
The handler result determines the downstream Promise: return a value, throw, or return another Promise to adopt.

Return, throw, adopt

This table is the core Promise-chain reasoning tool.

Handler resultDownstream Promise behavior
returns a plain valuefulfills with that value
returns normally with no valuefulfills with undefined
throwsrejects with the thrown reason
returns a fulfilled Promise/thenableadopts it and eventually fulfills with that outcome
returns a rejected Promise/thenableadopts it and eventually rejects with that outcome
returns a still-pending Promise/thenablebecomes resolved to/adopts it while remaining pending

Return a value

const p0 = Promise.resolve(10);
const p1 = p0.then((value) => value * 2);

p1 fulfills with 20.

Return nothing

const p1 = p0.then(() => {
  recordMetric();
});

A function that completes normally without an explicit return returns undefined, so p1 fulfills with undefined. This commonly breaks chains when downstream work expected a value or Promise.

Throw

const p1 = p0.then(() => {
  throw new Error('boom');
});

The throw rejects p1; it does not mutate p0.

Return another Promise

const p1 = p0.then(() => loadUser());

If loadUser() returns a Promise, p1 adopts that Promise's eventual outcome. Normal Promise chains therefore flatten asynchronous dependencies instead of giving application code a useful nested Promise<Promise<T>> shape.

Check yourself: what does this chain settle to?

Predict the fulfillment value of p1 before opening the answer.

const p0 = Promise.resolve({ id: 7 });
const p1 = p0.then((user) => {
  loadProfile(user.id); // intentional omission for this exercise
  return user.id;
});

Assume loadProfile() returns a Promise that fulfills with a profile object.

Show the reasoning
  • The handler returns user.id (7) as a plain value, so p1 fulfills with 7.
  • loadProfile(user.id) still starts, but its Promise is not adopted by p1 because it was not returned.
  • Downstream code that awaits p1 therefore continues with 7 while profile loading may still be unfinished—an ordering bug that looks “async” but is not sequenced.

Return a thenable

const thenable = {
  then(resolve) {
    resolve(42);
  },
};

const p1 = Promise.resolve().then(() => thenable);

In application code, prefer real Promises from trustworthy APIs. Thenable assimilation is mainly important so the language can interoperate with Promise-like values.

Try it: Promise Resolution Lab

The lab below does not execute arbitrary JavaScript. Native Promises do not expose all internal resolution/adoption metadata through a public inspection API, so the lab uses a deterministic teaching model that makes those semantics visible.

Promise Resolution Lab

Step through predefined Promise-resolution scenarios. The lab models language semantics for teaching; it does not execute arbitrary JavaScript or inspect hidden native Promise state.
A fulfilled source runs its then handler, and the handler return value fulfills a distinct downstream promise.
const p0 = Promise.resolve(10);
const p1 = p0.then((value) => value * 2);
Step 0
Status: In progress

Promise states

P0

Source promise

State:
Fulfilled
Resolution:
Fulfilled with value
Value:
10

Active handler

None

Outcome log

No output yet

Why this step?

Start with the source promise state. Chain methods create new promises; they do not mutate this source promise into the downstream result.

Pay special attention to Adopt a still-pending promise. The lab intentionally shows:

P1 state: Pending
P1 resolution: Adopting another promise
P1 adopts: P2
P2 state: Pending

That is a concrete resolved-but-not-fulfilled state.

Error propagation and recovery

Promise error propagation and recovery
  1. throw Error
  2. then(onFulfilled)
    Skipped
  3. catch(onRejected)
    Handles rejection
  4. return fallback
    Downstream fulfills
  5. forget to rethrow?
    May accidentally swallow failure
Rejections skip missing fulfillment handlers until a rejection handler runs; returning from catch recovers the chain.

When the current outcome does not match a supplied handler, that outcome propagates until a matching handler is reached.

catch() can recover a chain

loadUser()
  .catch(() => ({ name: 'Guest' }))
  .then(renderUser);

If loadUser() rejects and the catch() handler returns the fallback object normally, the Promise returned by catch() becomes fulfilled with that object. The following fulfillment handler can run normally.

Logging is not the same as propagating

loadUser().catch((error) => {
  log(error);
});

This rejection handler returns normally with undefined, so the Promise returned by catch() fulfills with undefined. If the error must remain a failure, rethrow it or return a rejected Promise.

Check yourself: does catch() stop later handlers?

fetchCart()
  .catch((error) => {
    report(error);
    return emptyCart();
  })
  .then((cart) => renderCheckout(cart));

If fetchCart() rejects and emptyCart() returns { items: [] }, does renderCheckout run?

Show the reasoning
  • Yes. The catch() handler recovers by returning a normal cart value, so the Promise returned by catch() fulfills.
  • The following then() therefore runs with that recovered cart.
  • Recovery is intentional here. The dangerous case is logging-only catch() that returns undefined and still lets a later “success” step run.

finally() is transparent until cleanup fails or delays

finally() is primarily for cleanup that should run regardless of fulfillment or rejection.

A normally completing finally() callback preserves the original fulfillment value or rejection reason. If it throws or returns a rejected Promise, that cleanup failure becomes the downstream rejection. If it returns a pending Promise, propagation waits for cleanup to settle.

This is why promise.finally(onFinally) is not equivalent to promise.then(onFinally, onFinally): value/reason propagation semantics differ.

Branching is not sequencing

Calling then() multiple times on one source Promise creates separate downstream Promises:

const source = Promise.resolve(10);

const a = source.then((value) => value + 1);
const b = source.then((value) => value * 2);
          ┌─ handler A → a fulfilled with 11
source 10 ┤
          └─ handler B → b fulfilled with 20

It is not source → handler A → handler B unless you actually chain B from A. This distinction connects directly to Async Waterfalls: Overlap Independent Work Without Breaking Dependencies: chain shape represents dependency shape.

Promise combinators by intent

Promise combinator contracts
OptionSuccess conditionFailure behavior
Promise.allEvery input fulfillsRejects on first observed rejection
Promise.allSettledWaits for every input to settleReturns status records instead of failing fast
Promise.raceFirst input to settle decidesCan fulfill or reject first
Promise.anyFirst fulfillment winsRejects only if all reject
Choose the combinator whose success and failure contract matches the operation you are coordinating.
NeedAPIKey failure behavior
all inputs must fulfillPromise.all()rejects when an input rejects
observe every input outcomePromise.allSettled()fulfills with per-input result records
first fulfillment winsPromise.any()rejects with AggregateError if all reject
first settlement winsPromise.race()settles with the first settled input

Promise.all() is useful when every input matters. Aggregate rejection does not automatically cancel other already-started operations.

Promise.allSettled() waits for every input and fulfills with per-input { status, value/reason } records.

Promise.any() fulfills with the first fulfillment; if every input rejects, it rejects with an AggregateError.

Promise.race() settles with the first fulfillment or rejection. Timeout-style races still do not automatically cancel the losing operation.

Empty inputs follow the combinator contract

Promise.all([])        -> already fulfilled with []
Promise.allSettled([]) -> already fulfilled with []
Promise.any([])        -> already rejected with AggregateError
Promise.race([])       -> remains pending

Even when an aggregate Promise is already settled, a later .then(...) reaction follows normal Promise reaction scheduling. “Already fulfilled” does not mean the reaction handler runs inline.

Modern Promise APIs in 2026

Promise.withResolvers()

Promise.withResolvers() returns one new Promise together with its resolving functions:

const { promise, resolve, reject } = Promise.withResolvers();

It is useful when the lifecycle owner needs those functions outside a constructor callback—for example event, queue, stream, or protocol integration. Keep resolve and reject near the subsystem that owns the lifecycle; do not turn the API into globally mutable Promise state.

Promise.try()

ECMAScript 2026 includes Promise.try():

const result = Promise.try(fn, arg1, arg2);

It is useful at boundaries where a callback may return a plain value, throw synchronously, or return a Promise/thenable. Promise.try() invokes the callback synchronously, then resolves or rejects the returned Promise from the callback's completion.

Promise.try(fn) is not timing-equivalent to Promise.resolve().then(fn)

const events = [];

Promise.try(() => {
  events.push('try callback');
});

Promise.resolve().then(() => {
  events.push('then callback');
});

events.push('sync end');

Before the current synchronous JavaScript finishes, the array has already seen:

try callback
sync end

The then() callback runs later as Promise reaction work.

Cancellation and operation ownership

A Promise models an eventual result; it does not inherently own or cancel the underlying operation.

For example, Fetch cancellation belongs to the Fetch operation through AbortSignal:

const controller = new AbortController();

const request = fetch('/api/report', {
  signal: controller.signal,
});

controller.abort();

Likewise, Promise.race([request, timeout]) or Promise.all([a, b, c]) does not automatically cancel losing or remaining operations. If cancellation matters, use the underlying API's cancellation mechanism and define lifecycle ownership.

How async / await connects

async / await is syntax built on Promise semantics, not a separate asynchronous model. An async function returns a Promise; await makes the surrounding async-function continuation depend on an eventual outcome. Fulfillment resumes with a value; rejection behaves like a throw at the await expression.

The syntax can still create an accidental sequential waterfall when independent operations are awaited one after another.

Production mistakes to avoid

Production scenario: The "swallowed error" phantom confirmation

A backend payment service coordinates order settlement using a Promise chain:

function processOrderPayment(orderId: string) {
  return chargeCustomer(orderId)
    .catch((err) => {
      // Developer logs the error but forgets to rethrow:
      logger.error('Failed to charge card', { orderId, err });
    })
    .then(() => {
      // This step STILL RUNS because the .catch() above returned undefined (fulfilled)!
      return markOrderAsPaidAndDispatch(orderId);
    });
}
  • Impact: When a customer's card is declined or has insufficient funds, chargeCustomer rejects. However, .catch() merely logs the error and returns cleanly (return undefined), transforming the downstream Promise into a fulfilled state! The system promptly calls markOrderAsPaidAndDispatch(orderId), dispatching goods to the customer without collecting payment!
  • Root cause: The developer treated .catch() as a passive error listener rather than a recovery handler. Unless an error is explicitly rethrown, downstream consumers treat the branch as successfully handled and recovered.
  • Correct pattern: When logging or observing a failure without recovering, always rethrow the error:
    return chargeCustomer(orderId)
      .catch((err) => {
        logger.error('Failed to charge card', { orderId, err });
        throw err; // Propagate the rejection downstream
      })
      .then(() => markOrderAsPaidAndDispatch(orderId));

Wrapping Promise APIs unnecessarily

Avoid constructing another Promise merely to forward an existing Promise API's resolve and reject. Return or transform the existing Promise unless you need genuinely different lifecycle semantics.

Forgetting to return asynchronous work from a handler

loadUser()
  .then((user) => {
    saveUser(user); // forgot return
  })
  .then(() => {
    showToast('Saved');
  });
  • Impact: The toast can appear while saveUser is still in flight—or after it later rejects—so the product reports success before durability is known.
  • Root cause: The first handler returns undefined immediately, so the downstream Promise fulfills without adopting saveUser's Promise.
  • Correct pattern: Return the asynchronous work you intend to sequence:
loadUser()
  .then((user) => saveUser(user))
  .then(() => {
    showToast('Saved');
  });

Accidentally swallowing a rejection

A catch() handler that only logs and then returns normally converts the downstream outcome into fulfillment with undefined. Decide whether recovery is intentional.

Assuming rejection cancels work

It does not. Tie cancellation to the actual operation API.

Creating unbounded work because aggregation looks simple

await Promise.all(items.map(processItem));

calls processItem for every element before Promise.all() waits for the aggregate. If those calls immediately start resource-consuming work, this can overwhelm databases, APIs, file descriptors, memory, or rate limits. Promise aggregation and concurrency limits solve different problems.

Confusing unhandled rejection policy with Promise semantics

A rejection is a Promise outcome. What a browser, Node.js process, test runner, or framework logs, reports, or terminates because a rejection is unhandled is runtime policy around that outcome.

Exercise

Predict the settlement state and values of p0 through p4:

let settleInner;

const inner = new Promise((resolve) => {
  settleInner = resolve;
});

const p0 = Promise.resolve(2);
const p1 = p0.then((value) => value * 3);
const p2 = p1.then(() => {
  throw new Error('boom');
});
const p3 = p2.catch(() => inner);
const p4 = p3.then((value) => value + 1);

settleInner(10);
Show the reasoning
  • p0 fulfills with 2.
  • p1 fulfills with 6 (2 * 3).
  • p2 rejects with Error('boom') due to the synchronous throw inside the handler.
  • When p2 rejects, the catch() handler catches it and returns the still-pending inner Promise. At this point, p3 is pending but resolved/adopting inner.
  • Once settleInner(10) is invoked, inner fulfills with 10. Consequently, p3 fulfills with 10.
  • Finally, p4 receives 10, adds 1, and fulfills with 11.

Agent rule

For standard Promise chaining, treat each then(), catch(), and finally() call as producing a separate downstream Promise. Determine that downstream outcome from the relevant handler: returning a value fulfills it, throwing rejects it, and returning a Promise/thenable makes it adopt that outcome. Do not equate resolved with fulfilled, do not assume rejection cancels underlying work, and choose combinators according to the success/failure contract you need.

When changing Promise-heavy code, verify against this checklist:

  • Downstream Promise awareness: Does each .then(), .catch(), or .finally() call explicitly account for returning a new downstream Promise?
  • Return vs Throw contract: Does every handler explicitly return a value/Promise or throw an error, rather than accidentally falling through to undefined?
  • Error recovery vs swallow: Is each .catch() intentional about either recovering with fallback data or re-throwing to preserve failure signals?
  • Cleanup transparency: Does .finally() perform side-effect cleanup without attempting to transform fulfillment values?
  • Combinator semantics: Does the selected combinator (all, allSettled, race, any) match the required failure mode (fail-fast vs complete observation)?
  • Cancellation decoupling: Are cancellation needs attached to AbortSignal rather than assuming a rejected Promise cancels pending network/disk I/O?
  • Concurrency boundaries: Is mass asynchronous fan-out bounded (e.g. queue/pool) rather than blindly launching unbounded Promise.all(items.map(...))?

Primary sources

This lesson was last verified on 2026-09-10 and is classified as evolving with a 180-day review target.

On this page