Promises: PromiseCapability and the Resolution Algorithm
Promise is not syntactic sugar — it is a complete state-machine protocol. The extra microtask fired when you call resolve(thenable), the reason .then always returns a new Promise unrelated to the original — all of this behavior derives from precise algorithms defined in the specification. Without understanding those algorithms, you cannot predict the behavior.
🔹 Level 1 · What You Need to Know
Three Promise States
pending
/ \
fulfilled rejected
- pending: Initial state — neither fulfilled nor rejected
- fulfilled: Operation succeeded; holds an immutable value
- rejected: Operation failed; holds an immutable reason
State transitions are one-way and one-time. Once a Promise is settled (fulfilled or rejected), its state never changes.
Core API Quick Reference
// Creation
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve('done'), 1000);
});
// Chaining
p.then(value => 'handle success') // returns a new Promise
.catch(err => 'handle error') // same as .then(null, onRejected)
.finally(() => 'cleanup'); // passes the value/reason through unchanged
// Static methods
Promise.resolve(value) // create an immediately fulfilled Promise
Promise.reject(reason) // create an immediately rejected Promise
Four Combination Methods Compared
| Method | Fulfills when | Rejects when | Return value |
|---|---|---|---|
Promise.all(iterable) |
All fulfilled | Any one rejects | Array of values (same order as input) |
Promise.race(iterable) |
First to settle | Same | Value or reason of the first settled |
Promise.allSettled(iterable) |
All settled (any outcome) | Never rejects | Array of {status, value/reason} |
Promise.any(iterable) |
Any one fulfills | All reject | Value of the first fulfilled |
Value-Passing Rules in Chains
Promise.resolve(1)
.then(v => v + 1) // v = 1, returns 2
.then(v => { // v = 2
throw new Error('oops'); // throwing → rejected
})
.then(v => v * 10) // skipped (upstream rejected)
.catch(e => e.message) // catches, returns 'oops' → fulfilled
.then(v => console.log(v)); // v = 'oops'
Key points:
- A
.thencallback's return value becomes the next Promise's fulfilled value - Throwing an exception is equivalent to returning a rejected Promise
- After
.catchreturns a normal value, the chain resumes as fulfilled
🔸 Level 2 · How It Actually Works
Promise Internal Slots
Each Promise object maintains the following internal state (the spec uses double brackets to denote internal slots):
Promise internal structure:
┌────────────────────────────────────────────────────────┐
│ [[PromiseState]] │
│ 'pending' | 'fulfilled' | 'rejected' │
│ │
│ [[PromiseResult]] │
│ When fulfilled: the resolution value │
│ When rejected: the rejection reason │
│ When pending: undefined │
│ │
│ [[PromiseFulfillReactions]] │
│ List of PromiseReaction records │
│ (success callbacks registered via .then while │
│ pending) │
│ │
│ [[PromiseRejectReactions]] │
│ List of PromiseReaction records │
│ (failure callbacks registered via .catch/.then │
│ while pending) │
│ │
│ [[PromiseIsHandled]] │
│ Boolean — whether a rejection handler exists │
│ (used for unhandledRejection detection) │
└────────────────────────────────────────────────────────┘
The resolve Function Execution Flow
The resolve function passed to new Promise((resolve, reject) => {...}) follows this exact logic:
┌──────────────────────────────────────────────────────────────┐
│ Promise Resolve Function Execution Flow │
│ │
│ Call resolve(value) │
│ │ │
│ ▼ │
│ Is [[PromiseState]] 'pending'? │
│ │ No → return immediately (idempotent: settled │
│ │ Promise ignores further resolve/reject calls) │
│ │ Yes ↓ │
│ ▼ │
│ Is value === this Promise? │
│ │ Yes → reject(TypeError: Chaining cycle detected) │
│ │ No ↓ │
│ ▼ │
│ Is IsCallable(value.then) true? (Is value a thenable?) │
│ │ Yes → EnqueueJob(NewPromiseResolveThenableJob, │
│ │ [promise, value, then]) │
│ │ (Note: this introduces an extra microtask) │
│ │ No ↓ │
│ ▼ │
│ Set [[PromiseState]] = 'fulfilled' │
│ Set [[PromiseResult]] = value │
│ TriggerPromiseReactions(fulfillReactions, value) │
│ (enqueue all fulfillReactions as PromiseJobs) │
└──────────────────────────────────────────────────────────────┘
Why resolve(thenable) Costs an Extra Microtask
const p1 = Promise.resolve(42); // already fulfilled
// Case 1: resolve a plain value
new Promise(resolve => resolve(42))
.then(v => console.log('A', v)); // executes after 1 microtask
// Case 2: resolve a thenable (including a Promise)
new Promise(resolve => resolve(p1)) // resolve receives a thenable!
.then(v => console.log('B', v)); // executes after 2 microtask levels
What happens internally:
Case 2 microtask queue evolution:
After sync code ends:
Queue = [NewPromiseResolveThenableJob(p_outer, p1)]
Execute NewPromiseResolveThenableJob:
Calls p1.then(resolve_outer, reject_outer)
Since p1 is already fulfilled, immediately enqueues:
Queue = [PromiseReactionJob(resolve_outer, 42)]
Execute PromiseReactionJob:
Calls resolve_outer(42); p_outer becomes fulfilled
Enqueues the .then callback:
Queue = [PromiseReactionJob(B_callback, 42)]
Execute B_callback:
Prints 'B 42'
Compared to Case 1 (which only has the last two steps), Case 2 has 2 additional microtask levels.
Internal Implementation of .then
// Simplified PerformPromiseThen logic from the spec
Promise.prototype.then = function(onFulfilled, onRejected) {
// 1. Create a new Promise (using SpeciesConstructor)
const C = SpeciesConstructor(this, Promise);
const resultCapability = NewPromiseCapability(C);
// 2. Normalize callbacks (replace non-functions with pass-through)
const fulfillReaction = new PromiseReaction(
resultCapability, 'Fulfill',
IsCallable(onFulfilled) ? onFulfilled : undefined
);
const rejectReaction = new PromiseReaction(
resultCapability, 'Reject',
IsCallable(onRejected) ? onRejected : undefined
);
// 3. Decide behavior based on current state
if (this.[[PromiseState]] === 'pending') {
this.[[PromiseFulfillReactions]].push(fulfillReaction);
this.[[PromiseRejectReactions]].push(rejectReaction);
} else if (this.[[PromiseState]] === 'fulfilled') {
EnqueueJob('PromiseJobs', PromiseReactionJob,
[fulfillReaction, this.[[PromiseResult]]]);
} else {
EnqueueJob('PromiseJobs', PromiseReactionJob,
[rejectReaction, this.[[PromiseResult]]]);
}
// 4. Return the new Promise
return resultCapability.[[Promise]];
};
Key insight: .then always returns a brand new Promise (created via NewPromiseCapability). The original and new Promise are linked through a PromiseReaction record: the original's result triggers the reaction; the reaction's return value resolves or rejects the new Promise.
PromiseCapability Record
PromiseCapability is a spec record structure that tracks a Promise alongside its paired resolve and reject functions:
PromiseCapability Record:
[[Promise]] — the associated Promise object
[[Resolve]] — the resolve function (call to fulfill the Promise)
[[Reject]] — the reject function (call to reject the Promise)
NewPromiseCapability(C) works by:
- Calling
new C(executor), where executor capturesresolveandreject - Returning
{ [[Promise]]: p, [[Resolve]]: resolve, [[Reject]]: reject }
This is the mechanism that lets static methods like Promise.all control Promises created externally.
Complete Data Flow of a Promise Chain
resolve('done')
│
▼
Promise P1 [fulfilled: 'done']
│
│ .then(v => v.toUpperCase())
▼
PromiseReactionJob runs, calls callback('done')
│
▼
callback returns 'DONE'
│
▼
resultCapability.[[Resolve]]('DONE')
│
▼
Promise P2 [fulfilled: 'DONE']
│
│ .then(v => console.log(v))
▼
prints 'DONE'
🔺 Level 3 · How the Spec Defines It
Spec §27.2: Promise Objects
Promise Resolve Functions (§27.2.1.3.2):
- Let F be the active function object.
- Assert: F has a [[Promise]] internal slot whose value is an Object.
- Let promise be F.[[Promise]].
- Let alreadyResolved be F.[[AlreadyResolved]].
- If alreadyResolved.[[Value]] is true, return undefined.
- Set alreadyResolved.[[Value]] to true.
- If SameValue(resolution, promise) is true: a. Let selfResolutionError be a newly created TypeError object. b. Perform RejectPromise(promise, selfResolutionError). c. Return undefined.
- If resolution is not an Object, then a. Perform FulfillPromise(promise, resolution). b. Return undefined.
- Let then be Completion(Get(resolution, "then")).
- If then is an abrupt completion, then a. Perform RejectPromise(promise, then.[[Value]]). b. Return undefined.
- Let thenAction be then.[[Value]].
- If IsCallable(thenAction) is false: a. Perform FulfillPromise(promise, resolution). b. Return undefined.
- Let thenJobCallback be HostMakeJobCallback(thenAction).
- Let job be NewPromiseResolveThenableJob(promise, resolution, thenJobCallback).
- Perform HostEnqueuePromiseJob(job.[[Job]], job.[[Realm]]).
- Return undefined.
FulfillPromise (§27.2.1.4):
- Assert: The value of promise.[[PromiseState]] is pending.
- Let reactions be promise.[[PromiseFulfillReactions]].
- Set promise.[[PromiseResult]] to value.
- Set promise.[[PromiseFulfillReactions]] to undefined.
- Set promise.[[PromiseRejectReactions]] to undefined.
- Set promise.[[PromiseState]] to fulfilled.
- Perform TriggerPromiseReactions(reactions, value).
- Return unused.
PerformPromiseThen (§27.2.5.4.1) is the core implementation of .then. It handles creating fulfillReaction and rejectReaction records, immediately enqueuing a Job if the Promise is already settled or pushing onto the reaction lists if still pending, and returning a new Promise via resultCapability.
The unhandledRejection Detection Mechanism
The spec defines HostPromiseRejectionTracker, which host environments implement to track unhandled rejections:
HostPromiseRejectionTracker(promise, operation):
operation can be:
'reject' — Promise was rejected (may not yet have a handler)
'handle' — Promise's rejection was handled (.catch added, etc.)
Browser implementation strategy:
- On rejection (
operation = 'reject'): add Promise to a "pending check" list - At the end of the current microtask checkpoint: check which Promises still have
[[PromiseIsHandled]] = false - Fire
unhandledrejectionevent for those Promises
💎 Level 4 · Edge Cases and Traps
Trap 1: Promise.resolve(aPromise) — Identity Preservation
const p1 = new Promise(resolve => resolve(42));
const p2 = Promise.resolve(p1);
console.log(p1 === p2); // true!
// But:
class MyPromise extends Promise {}
const mp = new MyPromise(resolve => resolve(42));
const p3 = Promise.resolve(mp);
console.log(mp === p3); // false!
// Promise.resolve checks if mp.constructor === Promise.
// mp is a MyPromise instance, so a new Promise is created.
Spec behavior: Promise.resolve(x) returns x directly only when x is a Promise and x.constructor === Promise. This optimization avoids unnecessary wrapping.
Trap 2: The Extra Microtasks from resolve(thenable) in Detail
// Code A: resolve a plain value
let resolveA;
const pA = new Promise(r => { resolveA = r; });
pA.then(() => console.log('A done'));
resolveA(42);
Promise.resolve().then(() => console.log('plain'));
// Code B: resolve an already-fulfilled Promise
let resolveB;
const pB = new Promise(r => { resolveB = r; });
pB.then(() => console.log('B done'));
resolveB(Promise.resolve(42)); // thenable → extra microtask levels
Promise.resolve().then(() => console.log('plain'));
// Code A output: A done → plain
// Code B output: plain → B done
This ordering difference can cause subtle bugs in concurrency control code. Best practice: if you know the value is not a thenable, use resolve(value) directly rather than resolve(Promise.resolve(value)).
Trap 3: The Subtle Difference Between .then(null, onRejected) and .catch(onRejected)
// These look the same but have one important difference:
promise.then(onFulfilled, onRejected); // Form 1
promise
.then(onFulfilled)
.catch(onRejected); // Form 2
// The difference: when onFulfilled throws an error:
// Form 1: onRejected does NOT catch errors thrown by onFulfilled
// (same .then — onFulfilled's error propagates downstream)
// Form 2: .catch DOES catch errors thrown by onFulfilled
function riskyFulfill(v) {
throw new Error('fulfilled handler failed');
}
Promise.resolve(1)
.then(riskyFulfill, err => console.log('caught in then:', err.message));
// Not caught — error propagates downstream
Promise.resolve(1)
.then(riskyFulfill)
.catch(err => console.log('caught in catch:', err.message));
// Caught: 'caught in catch: fulfilled handler failed'
Trap 4: Promise.all Short-Circuit Behavior and Resource Concerns
async function fetchAll() {
const results = await Promise.all([
fetch('/api/1'), // succeeds
fetch('/api/2'), // suppose this rejects
fetch('/api/3'), // succeeds, but result is ignored
]);
return results;
}
Key facts:
- Promise.all short-circuits — as soon as one Promise rejects, the returned Promise immediately rejects
- The other two
fetchcalls keep running; their results are just discarded - If fetches have side effects (writes, resource locks), those effects still happen
- Use AbortController to truly cancel:
const controller = new AbortController();
try {
const results = await Promise.all([
fetch('/api/1', { signal: controller.signal }),
fetch('/api/2', { signal: controller.signal }),
fetch('/api/3', { signal: controller.signal }),
]);
} catch (e) {
controller.abort(); // actually cancel remaining requests
throw e;
}
Trap 5: The Promise Constructor Executor Runs Synchronously
console.log('before');
new Promise((resolve) => {
console.log('executor'); // synchronous!
resolve(1); // synchronous call!
console.log('after resolve'); // also synchronous!
});
console.log('after');
// Output: before → executor → after resolve → after
// .then callbacks are microtasks (async), but the executor itself is sync
This means running heavy computation inside the executor blocks the main thread, contradicting the "asynchronous" appearance of Promises. A Promise's asynchrony comes from .then callbacks being scheduled as microtasks — not from the executor itself.
Summary
- Promise has 5 critical internal slots:
[[PromiseState]],[[PromiseResult]],[[PromiseFulfillReactions]],[[PromiseRejectReactions]], and[[PromiseIsHandled]]. resolve(thenable)triggersNewPromiseResolveThenableJob, adding 2 extra microtask levels compared toresolve(value)— the callback executes later than you might expect.Promise.resolve(p)returnspdirectly whenpis a Promise with the same constructor — no new object is created..then(onFulfilled, onRejected)does not catch errors thrown byonFulfilled; a chained.catchdoes.- Promise.all's other Promises keep running after a short-circuit reject; use AbortController for true cancellation.