skip to Main Content

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!

2

Answers


  1. The difference is when your microtasks are added.

    Promise.resolve().then(...)
    

    When JS calls this, it’s calling .then() on an already fulfilled promise, so it can add this handler to the microtask queue immediately as this line is executed (spec link), and so the callback will be executed as part of the next evaluation of the microtask queue.

    Promise.race([...]).then(...)
    

    Promise.race() doesn’t return a fulfilled promise immediately. Instead, it needs to handle the (first) fulfilment of the promises you pass it, and then in a subsequent tick resolve/reject itself based on this result, which then eventually results in your race-.then()/.catch() handlers from your code being called.

    You can think of race doing this internally for your case (spec link):

    race(iterable) {
      const raceResolvers = Promise.withResolvers();
      for (const thenable of iterable) {
        const nextPromise = Promise.resolve(thenable); 
        nextPromise.then(raceResolvers.resolve, raceResolvers.reject);
      }
      return raceResolvers.promise;
    }
    

    Internally the promise returned by Promise.race() is resolved by promise1/promise2 having .then() callbacks attached to them, meaning that you have an additional step:

    1. First the .then() handlers added internally by .race() are queued as microtasks
    2. Once these tasks run (ie: main script is finished executing and call stack is empty), the promise returned by .race() gets resolved, which then causes your .then((value) => { console.log('race:', value) }) handler to be added to the microtask queue

    It is in between steps 1 and the subsequent tick required for step 2 above where Promise.resolve().then(()=> {console.log('promise3')}); executes and is able add its handler onto the microtask queue, and that is why you see promise3 logged first before the race: log.

    Login or Signup to reply.
  2. 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 "mater" 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search