How it works
then does three things. It checks that this is a real promise (IsPromise), uses SpeciesConstructor to pick the constructor, and calls NewPromiseCapability(ctor) to make the result promise p2 with its resolve and reject. PerformPromiseThen then creates two reaction records, one for fulfill and one for reject, each holding the handler (or "empty") and the capability for p2.
If p is pending, the records are appended to its reaction lists. If p is already settled, a reaction job is enqueued immediately. When a pending promise settles, TriggerPromiseReactions enqueues one job per record in order.
The reaction job, NewPromiseReactionJob, is where chaining happens:
- If the handler is empty, the result is the incoming value (for fulfill) or a throw of the reason (for reject). That is the pass-through.
- Otherwise, call the handler with
undefined as this.
- If it returned normally, call
capability.[[Resolve]](result). If it threw, call capability.[[Reject]](error).
Step 3 is the same resolving function the constructor hands to an executor. So returning a promise or thenable follows the exact rules for resolve(x) in the previous question: a self-reference rejects with TypeError, a then getter that throws rejects, a callable then is called from a NewPromiseResolveThenableJob.
Edge cases
The extra ticks. A handler that returns a native promise does not fulfill p2 directly. p2's resolve function enqueues a NewPromiseResolveThenableJob (tick 2), which calls returned.then(resolveP2, rejectP2). The returned promise is already fulfilled, so that enqueues a reaction job (tick 3), which fulfills p2 and enqueues its handlers (tick 4).
Promise.resolve()
.then(() => { console.log('a1'); return Promise.resolve('x'); })
.then(() => console.log('a2'));
Promise.resolve()
.then(() => console.log('b1'))
.then(() => console.log('b2'))
.then(() => console.log('b3'))
.then(() => console.log('b4'));
// → a1
// → b1
// → b2
// → b3
// → a2
// → b4
Return 'x' instead of a promise and a2 runs right after b1, in tick 2. Ordering like this is spec-defined, because every engine must queue the same jobs; it is the observable cost of calling then on the returned promise instead of reading its state directly.
Pass-through and then never touching p:
const p = Promise.resolve('kept');
console.log(p.then() === p); // → false
p.then(null).then(undefined, 1).then(console.log); // → kept
Promise.reject(new Error('r'))
.then((value) => value) // skipped: reject passes through
.then(null, (error) => `recovered ${error.message}`)
.then(console.log); // → recovered r
Real-world scenario
Running async tasks one after another from a list: each .then waits for the previous task's returned promise because it adopts it.
const sleep = (ms, value) => new Promise((resolve) => setTimeout(resolve, ms, value));
const tasks = [() => sleep(20, 'a'), () => sleep(10, 'b'), () => sleep(1, 'c')];
const results = [];
tasks
.reduce(
(chain, task) => chain.then(task).then((value) => results.push(value)),
Promise.resolve(),
)
.then(() => console.log(results)); // → [ 'a', 'b', 'c' ]
Even though c is the fastest, the order is fixed by the chain. A plain for…of with await is the same thing with less ceremony; the reduce form still appears in older code and in interview questions.
Trade-offs
Each then allocates a promise, two reaction records and a job, even when you ignore the result. async/await avoids the visible nesting but uses the same machinery underneath. Chains are good when each step is a short pure transformation; once the steps need shared variables or loops, await reads better. Chains also keep errors in one place: a single .catch at the end covers every step before it.