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 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, andPromise.race()for raw settlement timers. - Fatal pitfall (Swallowed Errors): An error-handling
.catch()that merely logs an exception without rethrowing returnsundefined, 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: 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.
Promise state
Resolution
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 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)--> P3P0, P1, P2, and P3 are separate Promise objects. Each downstream Promise has its own outcome.
- p0Source Promise
- then(handler)Creates p1
- return valuep1 fulfills
- throw errorp1 rejects
- return Promisep1 adopts it
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.
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, sop1fulfills with7. loadProfile(user.id)still starts, but its Promise is not adopted byp1because it was not returned.- Downstream code that awaits
p1therefore continues with7while 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
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
- throw Error
- then(onFulfilled)Skipped
- catch(onRejected)Handles rejection
- return fallbackDownstream fulfills
- forget to rethrow?May accidentally swallow failure
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 bycatch()fulfills. - The following
then()therefore runs with that recovered cart. - Recovery is intentional here. The dangerous case is logging-only
catch()that returnsundefinedand 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 20It 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
| Option | Success condition | Failure behavior |
|---|---|---|
| Promise.all | Every input fulfills | Rejects on first observed rejection |
| Promise.allSettled | Waits for every input to settle | Returns status records instead of failing fast |
| Promise.race | First input to settle decides | Can fulfill or reject first |
| Promise.any | First fulfillment wins | Rejects only if all reject |
| 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
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,
chargeCustomerrejects. However,.catch()merely logs the error and returns cleanly (return undefined), transforming the downstream Promise into a fulfilled state! The system promptly callsmarkOrderAsPaidAndDispatch(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
saveUseris still in flight—or after it later rejects—so the product reports success before durability is known. - Root cause: The first handler returns
undefinedimmediately, so the downstream Promise fulfills without adoptingsaveUser'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
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
- Async Waterfalls: Overlap Independent Work Without Breaking Dependencies
- Browser Event Loop: How Tasks, Microtasks, and Rendering Are Scheduled
- 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-10 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.