javascriptpromisev8event-loop

What is the order of microtasks in multiple Promise chains?


Out of an academic interest I am trying to understand the order of microtasks in case of multiple Promise chains.

I. Two Promise chains

These execute in a predictable "zipped" way:

Promise.resolve()
  .then(log('a1'))
  .then(log('a2'))
  .then(log('a3'));

Promise.resolve()
  .then(log('b1'))
  .then(log('b2'))
  .then(log('b3'));
// a1 b1 ; a2 b2 ; a3 b3
// Here and in other output listings
// manually inserted semicolons ";" help illustrate my understanding.

II. The first "a1" returns a Promise:

Promise.resolve()
  .then(() => {
    log('a1')();
    return Promise.resolve();
  })
  .then(log('a2'))
  .then(log('a3'));

Promise.resolve()
  .then(log('b1'))
  .then(log('b2'))
  .then(log('b3'));
// a1 b1 ; b2 ; b3 a2 ; a3

As I understand, a new Promise returned from a then() has introduced a single extra microtask to resolve that Promise; which has also shifted the "A" then() to the end of the "loop". This allowed both "b2" and "b3" to overtake.

III. Three Promise chains

Here's the working code to try running, along with the helper functions:

const output = [];

const makeChain = (key, n = 5, trueStep = false) => {
  let p = Promise.resolve();
  const arrow = isArrow => isArrow ? '->' : '';
  for (let i = 1; i <= n; i++) {
    const returnPromise = trueStep === i;
    const afterPromise = trueStep === i - 1;
    p = p.then(() => {
      output.push(`${arrow(afterPromise)}${key}${i}${arrow(returnPromise)}`);
      if (returnPromise) return Promise.resolve();      
    });
  }
  return p.catch(console.error);
};

// ----- cut line for this and next tests -----

Promise
  .all([
    makeChain('a', 3),
    makeChain('b', 3),
    makeChain('c', 3),
  ])
  .then(() => console.log(output.join(' ')));

// a1 b1 c1 ; a2 b2 c2 ; a3 b3 c3

So far so good: a "zip" again.

IV. Return Promise at "a1" step

(skipping the helper funciton)

Promise
  .all([
    makeChain('a', 3, 1),
    makeChain('b', 3),
    makeChain('c', 3),
  ])
  .then(() => console.log(output.join(' ')));
// a1-> b1 c1 ; b2 c2 ; b3 c3 ->a2 ; a3

Looks like the same behavior: "A1" introduced a single extra microtask + jumped to the tail of the "loop".

V. "c4" also returns a Promise:

Promise
  .all([
    makeChain('a', 7, 1),
    makeChain('b', 7),
    makeChain('c', 7, 4),
  ])
  .then(() => console.log(output.join(' ')));

// a1-> b1 c1 ; b2 c2 ; b3 c3 ->a2 ; b4 c4-> a3 ; b5 a4 ; b6 a5 b7 ->c5 ; a6 c6 ; a7 c7

Two questions

  1. In the last test I cannot explain the order after "dispatching" the "c4": why did the "b7" overtook the "c5" then()?

  2. Where can I read about the rules behind the order of the microtasks in the PromiseJobs queue?


Solution

  • In principle, the order of Promise resolution is guaranteed in the specification (unlike other Jobs whose execution order is implementation defined):

    9.5.5 HostEnqueuePromiseJob

    [...] Jobs must run in the same order as the HostEnqueuePromiseJob invocations that scheduled them.

    The key idea to answer your question is that there is not one job involved in resolving a promise, there are two:

    To see where the difference comes from, lets simplify the example to:

    const settled = Promise.resolve();
    settled.then(() => console.log("settled"));
    
    Promise.resolve()
        .then(() => console.log("a1"))
        .then(() => console.log("a2"))
        .then(() => console.log("a3"))
        .then(() => console.log("a4"))
        .then(() => console.log("a5"))
    
    Promise.resolve()
        .then(() => { console.log("b1"); return settled; })
        .then(() => console.log("b2"));

    The key difference is that in one case, .then returns a Promise which adds three sequential jobs to the queue, whereas not returning a Promise from .then only schedules one job.

    27.2.1.3.2 Promise Resolve Functions

    8. If resolution is not an Object, then
       a. Perform FulfillPromise(promise, resolution). [<- One Job]
       b. Return undefined.
    
    9.  Let then be Completion(Get(resolution, "then")).
    11. Let thenAction be then.[[Value]].
    13. Let thenJobCallback be HostMakeJobCallback(thenAction).
    14. Let job be NewPromiseResolveThenableJob(promise, resolution, thenJobCallback). [<- First Job of three]
    

    The three sequential jobs are:


    One easy way to look behind the curtain and see the ResolveThenableJob is by returning a Thenable (an object with a .then method) instead of a Promise:

    const prom = Promise.resolve();
    prom.then(() => { 
        console.log("promise reaction job 1")
        return { then(res) { console.log("then job"); res(); } };
    }).then(() => console.log("promise reaction job 3"));
    prom.then(() => console.log("promise reaction job 2"));

    The existence of the concept of a Thenable is probably why this job exists in the first place.


    For what it is worth, this three-job frenzy also existed for await, then it was optimized away in V8 and afterwards the spec was changed to make this optimization mandatory. So maybe nobody cared to optimize away the three jobs when returning a Promise from within .then(...).