Promises: Resolution, Chaining, and Failure
Reason about Promise states, resolution, chaining, error recovery, adoption, combinators, and modern Promise APIs.
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:
- return a plain value → the downstream Promise fulfills with that value;
- throw → the downstream Promise rejects with the thrown reason;
- 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: 20Promise 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:
- Khởi tạo: Bắt đầu ở trạng thái
pending.- Chuyển trạng thái 1 chiều (Settled):
- Gọi
resolve(primitiveValue)-> Chuyển vĩnh viễn sangfulfilledvới giá trịvalue.- Gọi
reject(reason)-> Chuyển vĩnh viễn sangrejectedvớ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).
- 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áipendingcho đến khiinnerPromisehoàn tất.
The simple cases look unsurprising:
Promise.resolve(42); // resolved and fulfilled
Promise.reject(new Error()); // resolved and rejectedThe 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); // trueFor 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); // falseWhile inner is still pending:
inner: pending
outer: pending + resolved/adopting innerouter 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 42This 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 handlerFor 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)--> P3P0, 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:
- Return Value: Handler trả về
x-> downstream Promise lập tức fulfill vớix.- Throw Error: Handler ném lỗi
throw err-> downstream Promise lập tức reject vớierr.- Return Promise (Flattening/Adoption): Handler trả về một Promise khác
fetchData()-> downstream Promise sẽ "nhận nuôi" (adopt) và chỉ settle khifetchData()hoàn thành (không bị lồng thànhPromise<Promise<T>>).
Return, throw, adopt
This table is the core Promise-chain reasoning tool.
| Handler result | Downstream Promise behavior |
|---|---|
| returns a plain value | fulfills with that value |
| returns normally with no value | fulfills with undefined |
| throws | rejects with the thrown reason |
| returns a fulfilled Promise/thenable | adopts it and eventually fulfills with that outcome |
| returns a rejected Promise/thenable | adopts it and eventually rejects with that outcome |
| returns a still-pending Promise/thenable | becomes 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
const p0 = Promise.resolve(10);
const p1 = p0.then((value) => value * 2);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: PendingThat 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áifulfilledvà 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ạyconsole.log(err)mà quênthrow-> vô tình biến luồng lỗi thànhfulfilled 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 20It 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)]:
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.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).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.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).
| Need | API | Key failure behavior |
|---|---|---|
| all inputs must fulfill | Promise.all() | rejects when an input rejects |
| observe every input outcome | Promise.allSettled() | fulfills with per-input result records |
| first fulfillment wins | Promise.any() | rejects with AggregateError if all reject |
| first settlement wins | Promise.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 pendingEven 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 endThe 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,
chargeCustomerbị 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ọimarkOrderAsPaidAndDispatch(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
p0fulfills with2.p1fulfills with6(2 * 3).p2rejects withError('boom')due to the synchronous throw inside the handler.- When
p2rejects, thecatch()handler catches it and returns the still-pendinginnerPromise. At this point,p3is pending but resolved/adoptinginner. - Once
settleInner(10)is invoked,innerfulfills with10. Consequently,p3fulfills with10. - Finally,
p4receives10, adds1, and fulfills with11.
Agent rule
For standard Promise chaining, treat each
then(),catch(), andfinally()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
AbortSignalrather 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(...))?
Related lessons
- Avoiding Sequential Async Waterfalls
- How the Browser Event Loop Actually Works
- Async / Await — future lesson on expressing Promise dependencies clearly.
- Cancellation — future lesson on AbortSignal, ownership, propagation, and cleanup.
- Bounded Concurrency — future lesson on controlling resource pressure independently of Promise aggregation.
Primary sources
This lesson was last verified on 2026-09-09 and is classified as evolving with a 180-day review target.
- ECMAScript 2026 — Promise Objects — normative Promise states, resolution, constructor behavior, combinators,
Promise.try(),Promise.withResolvers(), and prototype methods. - ECMAScript 2026 —
Promise.resolve()/PromiseResolve— normative identity behavior for same-constructor Promises and creation of a new resolving Promise otherwise. - MDN — Promise — practical terminology and developer-facing reference.
- MDN — Promise constructor — executor timing and first-resolution behavior.
- MDN — Promise.prototype.then() — chaining and downstream-Promise behavior.
- MDN — Promise.prototype.finally() — cleanup transparency and exceptional outcomes.
- MDN — Promise.all(), allSettled(), any(), and race() — aggregation contracts and empty-input behavior.
- MDN — Promise.withResolvers() — standardized construction primitive and compatibility context.
- MDN — Promise.try() — standardized callback normalization and synchronous callback invocation.