Chapter 26

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

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:


🔸 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:

  1. Calling new C(executor), where executor captures resolve and reject
  2. 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):

  1. Let F be the active function object.
  2. Assert: F has a [[Promise]] internal slot whose value is an Object.
  3. Let promise be F.[[Promise]].
  4. Let alreadyResolved be F.[[AlreadyResolved]].
  5. If alreadyResolved.[[Value]] is true, return undefined.
  6. Set alreadyResolved.[[Value]] to true.
  7. If SameValue(resolution, promise) is true: a. Let selfResolutionError be a newly created TypeError object. b. Perform RejectPromise(promise, selfResolutionError). c. Return undefined.
  8. If resolution is not an Object, then a. Perform FulfillPromise(promise, resolution). b. Return undefined.
  9. Let then be Completion(Get(resolution, "then")).
  10. If then is an abrupt completion, then a. Perform RejectPromise(promise, then.[[Value]]). b. Return undefined.
  11. Let thenAction be then.[[Value]].
  12. If IsCallable(thenAction) is false: a. Perform FulfillPromise(promise, resolution). b. Return undefined.
  13. Let thenJobCallback be HostMakeJobCallback(thenAction).
  14. Let job be NewPromiseResolveThenableJob(promise, resolution, thenJobCallback).
  15. Perform HostEnqueuePromiseJob(job.[[Job]], job.[[Realm]]).
  16. Return undefined.

FulfillPromise (§27.2.1.4):

  1. Assert: The value of promise.[[PromiseState]] is pending.
  2. Let reactions be promise.[[PromiseFulfillReactions]].
  3. Set promise.[[PromiseResult]] to value.
  4. Set promise.[[PromiseFulfillReactions]] to undefined.
  5. Set promise.[[PromiseRejectReactions]] to undefined.
  6. Set promise.[[PromiseState]] to fulfilled.
  7. Perform TriggerPromiseReactions(reactions, value).
  8. 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:

  1. On rejection (operation = 'reject'): add Promise to a "pending check" list
  2. At the end of the current microtask checkpoint: check which Promises still have [[PromiseIsHandled]] = false
  3. Fire unhandledrejection event 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:

  1. Promise.all short-circuits — as soon as one Promise rejects, the returned Promise immediately rejects
  2. The other two fetch calls keep running; their results are just discarded
  3. If fetches have side effects (writes, resource locks), those effects still happen
  4. 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

  1. Promise has 5 critical internal slots: [[PromiseState]], [[PromiseResult]], [[PromiseFulfillReactions]], [[PromiseRejectReactions]], and [[PromiseIsHandled]].
  2. resolve(thenable) triggers NewPromiseResolveThenableJob, adding 2 extra microtask levels compared to resolve(value) — the callback executes later than you might expect.
  3. Promise.resolve(p) returns p directly when p is a Promise with the same constructor — no new object is created.
  4. .then(onFulfilled, onRejected) does not catch errors thrown by onFulfilled; a chained .catch does.
  5. Promise.all's other Promises keep running after a short-circuit reject; use AbortController for true cancellation.
Rate this chapter
4.6  / 5  (4 ratings)

💬 Comments