Javascript Promises curiosity

Promise.resolve()
  .then(() => console.log('a1'))
  .then(() => console.log('a2'))
  .then(() => console.log('a3'))
Promise.resolve()
  .then(() => console.log('b1'))
  .then(() => console.log('b2'))
  .then(() => console.log('b3'))

Instead of output a1, a2, a3, b1, b2, b3 you will see a1, b1, a2, b2, a3, b3 because of the same reason - every then returns a promise and it goes to the end of the event-loop queue. So we can see this "promise race". The same is when there are some nested promises.


This is kind of a cool question to get to the bottom of.

When you do this:

verifier(3,4).then(...)

that returns a new promise which requires another cycle back to the event loop before that newly rejected promise can run the .catch() handler that follows. That extra cycle gives the next sequence:

verifier(5,4).then(...)

a chance to run its .then() handler before the previous line's .catch() because it was already in the queue before the .catch() handler from the first one gets in the queue and items are run from the queue in FIFO order.


Note, that if you use the .then(f1, f2) form in place of the .then().catch(), it does run when you expect it to because there's no additional promise and thus no additional tick involved:

const verifier = (a, b) =>
  new Promise((resolve, reject) => (a > b ? resolve(true) : reject(false)));

verifier(3, 4)
  .then((response) => console.log("response (3,4): ", response),
        (error) => console.log("error (3,4): ", error)
  );

verifier(5, 4)
  .then((response) => console.log("response (5,4): ", response))
  .catch((error) => console.log("error (5,4): ", error));

Note, I also labeled all the messages so you can see which verifier() call they come from which makes it a lot easier to read the output.


ES6 Spec on promise callback ordering and more detailed explanation

The ES6 spec tells us that promise "jobs" (as it calls a callback from a .then() or .catch()) are run in FIFO order based on when they are inserted into the job queue. It doesn't specifically name FIFO, but it specifies that new jobs are inserted at the end of the queue and jobs are run from the beginning of the queue. That implements FIFO ordering.

PerformPromiseThen (which executes the callback from .then()) will lead to EnqueueJob which is how the resolve or reject handler gets scheduled to be actually run. EnqueueJob specifies that the pending job is added at the back of the job queue. Then the NextJob operation pulls the item from the front of the queue. This ensures FIFO order in servicing jobs from the Promise job queue.

So, in the example in the original question, we get the callbacks for the verifier(3,4) promise and the verifier(5,4) promise inserted into the job queue in the order they were run because both those original promises are done. Then, when the interpreter gets back to the event loop, it first picks up the verifier(3,4) job. That promise is rejected and there's no callback for that in the verifier(3,4).then(...). So, what it does is reject the promise that verifier(3,4).then(...) returned and that causes the verifier(3,4).then(...).catch(...) handler to be inserted into the jobQueue.

Then, it goes back to the event loop and the next job it pulls from the jobQueue is the verifier(5, 4) job. That has a resolved promise and a resolve handler so it calls that handler. This causes the response (5,4): output to be shown.

Then, it goes back to the event loop and the next job it pulls from the jobQueue is the verifier(3,4).then(...).catch(...) job where it runs that and this causes the error (3,4) output to be shown.

It's because the .catch() in the 1st chain is one promise level deeper in its chain than the .then() in the 2nd chain that causes the ordering you reported. And, it's because promise chains are traversed from one level to the next via the job queue in FIFO order, not synchronously.


General Recommendation About Relying on This Level of Scheduling Detail

FYI, in general, I try to write code that does not depend upon this level of detailed timing knowledge. While it's curious and occasionally useful to understand, it is fragile code as a simple seemingly innocuous change to the code can lead to a change in the relative timing. So, if timing is critical between two chains like this, then I would rather write the code in a way that forces the timing the way I want it rather than rely on this level of detailed understanding.