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.