This code, below, executes as a result:
script start
promise1
promise2
script end
promise3
race: A
Chrome version 131.0.6778.26, and node environment 22.4 both give the same result
Why is it that “‘race’: A” is executed after the execution of promise3, my understanding is that Promise.race is also a microtask which is first put into the microtask stack, so it should be executed first, but the result is not like this, this problem has been bothering me for a long time. I hope to be able to give some official explanation, if you can answer my confusion I will be very grateful!
console.log('script start')
const promise1 = new Promise((resolve, reject) => {
console.log('promise1')
resolve("A")
})
const promise2 = new Promise((resolve, reject) => {
console.log('promise2')
resolve("B")
})
const p = Promise.race([promise1, promise2])
.then((value) => {
console.log('race:', value)
}).catch((error) => {
console.log(error)
})
Promise.resolve().then(()=> {
console.log('promise3')
})
console.log('script end')
I'm hoping for some official explanations, going down to the source code level would be best!
The execution order is not related to a particular V8 version. This is the only possible sequence you can get by the ECMAScript specification.
Before making an analysis, I'll first rewrite the script in a way that every promise is assigned to a distinct variable, and every callback has a (function) name. That way we can uniquely reference all involved promises and functions.
So here is the rewritten code:
// All callback functions are defined with a name for easy reference in the analysis:
function p1_construct(resolve) {
console.log('promise1');
resolve("A");
}
function p2_construct(resolve) {
console.log('promise2');
resolve("B");
}
function p3_then() {
console.log('promise3');
}
function p5_then(value) {
console.log('race:', value);
}
function p6_catch(error) {
console.log(error);
}
console.log('script start');
const p1 = new Promise(p1_construct);
const p2 = new Promise(p2_construct);
const p5 = Promise.race([p1, p2]);
const p6 = p5.then(p5_then);
const p7 = p6.catch(p6_catch);
const p3 = Promise.resolve();
const p4 = p3.then(p3_then);
console.log('script end');
The table below depicts the actions row by row as they are executed as time progresses.
The "callstack" column indicates which function is executing (if any). "script" indicates the main script is executing (not a callback). Once the script has executed completely, the microtask queue will be checked for jobs, and if present the first job is extracted from it and executed. A promise-related job consists of executing a function and resolving the related promise with the function result. This pair of information (function and promise) is listed as a job entry in the last column, which represents the state of the microtask queue.
If an action changes the state of a promise, this change is indicated in the "promise state changes" column.
When a promise is fulfilled, and a then
callback is attached to it, then the microtask queue gets a new job added to it. This happens either when the promise is fulfilled or the then
callback is attached, whichever happens last.
step | callstack | action | promise state changes, assignments | microtask queue (FIFO) |
---|---|---|---|---|
1 | script | log('script start') |
- | |
2 | script | new Promise(p1_construct) |
new promise is pending (but not yet assigned) | - |
3 | script > p1_construct |
log('promise1') |
- | |
4 | script > p1_construct |
resolve("A") |
new promise is fulfilled (but not yet assigned) | - |
5 | script | p1 = <new promise> |
p1 is fulfilled |
- |
6 | script | new Promise(p2_construct) |
new promise is pending (but not yet assigned) | - |
7 | script > p2_construct |
log('promise2') |
- | |
8 | script > p2_construct |
resolve("A") |
new promise is fulfilled (but not yet assigned) | - |
9 | script | p2 = <new promise> |
p2 is fulfilled |
- |
10 | script | p5 = Promise.race([p1, p2]) |
p5 is pending. race registers then-callbacks. Queue. |
p1_then/p5 , p2_then/p5 |
11 | script | p6 = p5.then(p5_then) |
p6 is pending. Register p5_then |
p1_then/p5 , p2_then/p5 |
12 | script | p7 = p6.catch(p6_catch) |
p7 is pending. Register p6_catch |
p1_then/p5 , p2_then/p5 |
13 | script | p3 = Promise.resolve() |
p3 is fulfilled |
p1_then/p5 , p2_then/p5 |
14 | script | p4 = p3.then(p3_then) |
p4 is pending. Register p3_then . Queue. |
p1_then/p5 , p2_then/p5 , p3_then/p4 |
15 | script | log('script end') |
p1_then/p5 , p2_then/p5 , p3_then/p4 |
|
16 | - | check microtask queue | p1_then/p5 , p2_then/p5 , p3_then/p4 |
|
17 | p1_then |
resolve p5 |
p5 is fulfilled. Queue. |
p2_then/p5 , p3_then/p4 , p5_then/p6 |
18 | - | check microtask queue | p2_then/p5 , p3_then/p4 , p5_then/p6 |
|
19 | p2_then |
nothing (p5 already resolved) |
p3_then/p4 , p5_then/p6 |
|
20 | - | check microtask queue | p3_then/p4 , p5_then/p6 |
|
21 | p3_then |
log('promise3') |
p5_then/p6 |
|
22 | p3_then |
resolve p4 |
p4 is fulfilled. |
p5_then/p6 |
23 | - | check microtask queue | p5_then/p6 |
|
24 | p5_then |
log('race:', value) |
- | |
25 | p5_then |
resolve p6 |
p6 is fulfilled. |
passthru/p7 |
26 | - | check microtask queue | passthru/p7 |
|
27 | passthru |
resolve p7 |
p7 is fulfilled. |
- |
28 | - | check microtask queue | - |
Some highlights from the above table:
Notice the difference in the effect on the microtask queue in steps 11 and 14. In step 11, the call of p5.then(p5_then)
does not put anything in the queue, while in step 14, p3.then(p3_then)
does append an entry in the microtask queue. The reason for this difference is that in step 11 the promise p5
is not yet fulfilled, while in step 14 the promise p3
is fulfilled. For a then-job to be added to the microtask queue we need the promise to be fulfilled. If it is not yet fulfilled, the callback will only be added to the queue when the promise fulfills later on. For the promise in step 11 we need to look at step 17 where it gets fulfilled, and then the conditions are right to put the p5_then
job in the queue.
Some of the promises above don't have any then
callbacks attached to them, so when they fulfill, nothing much happens. This is the case for p4
, p7
: their fulfillment doesn't have any effect on the microtask queue.
As to p5
(the promise returned by Promise.race
), it is pending when created. This is because race
can only know about the states of the promises (that it got as argument) by attaching then
callbacks to them, which I have named p1_then
and p2_then
(it also attaches catch callbacks, which I have ignored here). These then
callbacks are callback functions that race
provides internally. So if one of the given promises is resolved, or will resolve, then at that moment the callback will be placed in the microtask queue (which we see in step 10). That means race
will only know "later" when a promise is resolved. So race
will always* return a pending promise, no matter what the states of the promises are that are provided to it.
race
processes the argument it received, in which case a rejected promise is returned. See ECMAScript specs