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 9, 2026Review target: 180 days
Edit on GitHub

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

TL;DR

A Promise represents an eventual outcome. The most useful way to read a chain is from one Promise to the next: every standard then(), catch(), or finally() call returns a separate downstream Promise.

Three rules explain most chains:

  1. return a plain value → the downstream Promise fulfills with that value;
  2. throw → the downstream Promise rejects with the thrown reason;
  3. return a Promise or thenable → the downstream Promise adopts that eventual outcome.

The terminology that prevents many mistakes is:

Resolved does not necessarily mean fulfilled.

A Promise can already be resolved to another still-pending Promise or thenable while remaining pending itself. Also, a Promise represents the result of work; rejecting it does not inherently cancel network I/O, a timer, filesystem work, or another underlying operation.

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.

🖼️ [Illustration Placeholder: Promise State Machine & Resolution vs Settlement]
Mô tả hình minh họa: Sơ đồ máy trạng thái (State Machine) của Promise:

  1. Khởi tạo: Bắt đầu ở trạng thái pending.
  2. Chuyển trạng thái 1 chiều (Settled):
    • Gọi resolve(primitiveValue) -> Chuyển vĩnh viễn sang fulfilled với giá trị value.
    • Gọi reject(reason) -> Chuyển vĩnh viễn sang rejected với lý do lỗi.
    • Một khi đã Settled thì không thể đổi trạng thái được nữa (Bất biến / Immutable).
  3. Trường hợp đặc biệt "Promise Adoption" (Resolved nhưng vẫn Pending):
    • Khi gọi resolve(innerPromise) -> Promise ngoài lập tức được xem là resolved (số phận đã ấn định, không thể reject để đổi ý), NHƯNG bản thân nó vẫn ở trạng thái pending cho đến khi innerPromise hoàn tất.

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 How the Browser Event Loop Actually Works.

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.

🖼️ [Illustration Placeholder: Downstream Promise Generation & Handler Outcomes]
Mô tả hình minh họa: Sơ đồ luồng dữ liệu qua các mắt xích Promise Chain (p0 -> then() -> p1 -> catch() -> p2):

  • Mỗi mắt xích là một Object Promise riêng biệt độc lập trong heap memory (p0 !== p1 !== p2).
  • Minh họa trực quan 3 kịch bản kết quả của một Handler:
    1. Return Value: Handler trả về x -> downstream Promise lập tức fulfill với x.
    2. Throw Error: Handler ném lỗi throw err -> downstream Promise lập tức reject với err.
    3. Return Promise (Flattening/Adoption): Handler trả về một Promise khác fetchData() -> downstream Promise sẽ "nhận nuôi" (adopt) và chỉ settle khi fetchData() hoàn thành (không bị lồng thành Promise<Promise<T>>).

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.

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

🖼️ [Illustration Placeholder: Promise Error Propagation & Recovery Mechanics]
Mô tả hình minh họa: Mô hình "thác đổ" (waterfall) của lỗi trong Promise chain:

  • Khi một mắt xích ném lỗi (throw Error), lỗi sẽ nhảy cóc qua tất cả các .then(onFulfilled) trung gian mà không thực thi chúng.
  • Điểm chạm .catch(onRejected):
    • Kịch bản Khôi phục (Recovery): Nếu .catch() return fallback data -> downstream Promise phía sau nó lập tức trở lại trạng thái fulfilled và các .then() tiếp theo lại chạy bình thường!
    • Kịch bản Bắt lỗi không trọn vẹn (Swallowed Error): .catch() chỉ chạy console.log(err) mà quên throw -> vô tình biến luồng lỗi thành fulfilled undefined, gây bug logic nguy hiểm cho các bước phía sau.

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.

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 Avoiding Sequential Async Waterfalls: chain shape represents dependency shape.

Promise combinators by intent

🖼️ [Illustration Placeholder: Promise Combinators Comparison Matrix & Behavior]
Mô tả hình minh họa: Bảng đồ họa so sánh trực quan 4 combinators với 3 Promise con [P1 (hoàn thành sớm, thành công), P2 (hoàn thành muộn, thành công), P3 (hoàn thành giữa chừng, thất bại)]:

  1. Promise.all: Đòi hỏi TẤT CẢ thành công. P3 fail -> lập tức reject toàn cục (Fail-fast), kết quả của P1/P2 bị bỏ qua.
  2. Promise.allSettled: Luôn chờ TẤT CẢ P1, P2, P3 hoàn thành -> Trả về mảng 3 records trạng thái {status: 'fulfilled' | 'rejected'} (an toàn tuyệt đối cho batch operations).
  3. Promise.race: Ai cán đích đầu tiên (dù thắng hay thua) sẽ quyết định số phận -> P1 xong trước -> resolve theo P1.
  4. Promise.any: Tìm người THÀNH CÔNG đầu tiên -> P1 thành công -> resolve theo P1. Chỉ reject khi CẢ BA cùng fail (trả về AggregateError).
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

Một service backend xử lý thanh toán đơn hàng bằng chuỗi Promise:

function processOrderPayment(orderId: string) {
  return chargeCustomer(orderId)
    .catch((err) => {
      // DEV chỉ ghi log lỗi charge thẻ nhưng quên rethrow:
      logger.error('Failed to charge card', { orderId, err });
    })
    .then(() => {
      // Bước này VẪN CHẠY vì .catch() phía trên trả về undefined (fulfilled)!
      return markOrderAsPaidAndDispatch(orderId);
    });
}
  • Hậu quả: Khi thẻ khách hàng hết hạn mức, chargeCustomer bị reject. Tuy nhiên .catch() chỉ ghi log rồi kết thúc bình thường (return undefined), biến downstream Promise thành fulfilled! Hệ thống lập tức gọi markOrderAsPaidAndDispatch(orderId), xuất kho và gửi hàng cho khách dù chưa thu được đồng nào!
  • Nguyên nhân cốt lõi: Lập trình viên quên quy tắc cơ bản: hàm .catch() không chỉ để "lắng nghe lỗi", nó là một trạm hồi phục (recovery). Nếu không ném lỗi tiếp, luồng phía sau sẽ coi như mọi chuyện đã được xử lý êm đẹp.
  • Cách khắc phục chuẩn: Nếu chỉ muốn quan sát lỗi mà không dập tắt thất bại, bắt buộc phải throw err:
    return chargeCustomer(orderId)
      .catch((err) => {
        logger.error('Failed to charge card', { orderId, err });
        throw err; // Tiếp tục propagate rejection xuống 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(() => {
    // This does not wait for saveUser if saveUser returns a Promise.
  });

Return saveUser(user) when the next link must depend on it.

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-09 and is classified as evolving with a 180-day review target.

On this page