Search code examples
javascriptecmascript-6es6-promisechaining

Why are .catch callbacks executed at the end of the microtask queue?


I have the following code in an HTML file:

const fooBar = function(resolve, reject) {
	let flag = (Math.round(Math.random() * 10) % 2);
	if(flag)
		resolve({ "value": "foo", "rand": Math.random() });
	else
		reject({ "value": "bar", "rand": Math.random() });
};
const fooBarSuccess1 = function(value) {
	console.log("Success 1:" + JSON.stringify(value));
};
const fooBarFailure1 = function(value) {
	console.log("Failure 1:" + JSON.stringify(value));
};
const fooBarSuccess2 = function(value) {
	console.log("Success 2:" + JSON.stringify(value));
};
const fooBarFailure2 = function(value) {
	console.log("Failure 2:" + JSON.stringify(value));
};
new Promise(fooBar).then(fooBarSuccess1).catch(fooBarFailure1);
new Promise(fooBar).then(fooBarSuccess2, fooBarFailure2);
console.log("Before setting MicroTask.");
setTimeout(() => console.log("This Timeout was set before the MicroTask!"));
queueMicrotask(() => console.log("From MicroTask!"));
console.log("After setting MicroTask.");

JSFiddle

When the Promise gets rejected, fooBarFailure1 is executed at the end of the microtask queue, so you might get the below output:

Before setting MicroTask.
After setting MicroTask.
Success 2:{"value":"foo","rand":0.3675094508130746}
From MicroTask!
Failure 1:{"value":"bar","rand":0.6828171208953322}
This Timeout was set before the MicroTask!

However, shouldn't it be invoked before the code inside queueMicrotask is executed? And I don't see any such issues with fooBarFailure2. It gets executed in the expected order. The result is the same in Firefox 71 and Google Chrome 78. Can anybody explain what's happening here?


Solution

  • The difference is that fooBarFailure1 is further away from the root promise (the one from new Promise) than fooBarFailure2 is. fooBarFailure1 isn't connected to the root promise, it's connected to the one created by .then(fooBarSuccess1):

    new Promise(fooBar).then(fooBarSuccess1).catch(fooBarFailure1);
    

    In contrast, fooBarSuccess2 and fooBarFailure2 are both attached to the root promise:

    new Promise(fooBar).then(fooBarSuccess2, fooBarFailure2);
    

    There's an internal rejection handler in the chain before fooBarFailure1, but the fooBarFailure2 is hooked directly. That's what causes the extra async "tick".

    Let's look at just the failure example, because it simplifies things:

    const success = function(value) {
        console.log("This never happens");
    };
    const fooBarFailure1 = function(value) {
        console.log("Failure 1");
    };
    const fooBarFailure2 = function(value) {
        console.log("Failure 2");
    };
    Promise.reject().then(success).catch(fooBarFailure1);
    Promise.reject().then(success, fooBarFailure2);
    console.log("Before setting MicroTask.");
    setTimeout(() => console.log("This Timeout was set before the MicroTask!"));
    queueMicrotask(() => console.log("From MicroTask!"));
    console.log("After setting MicroTask.");

    The output of that is:

    Before setting MicroTask.
    After setting MicroTask.
    Failure 2
    From MicroTask!
    Failure 1
    This Timeout was set before the MicroTask!
    

    Here's why:

    • Promise.reject() returns a rejected promise in both cases.
    • In Promise.reject().then(success).catch(fooBarFailure1);
      • .then(success) creates a new promise and hooks up fulfillment and rejection handlers; the rejection handler is internal and just passes on the rejection reason, since no rejection handler was supplied.
      • .catch(fooBarFailure1) hooks up a rejection handler on the promise from then.
    • Since the promise is rejected, it uses a microtask to call the rejection handler attached to it.
    • In Promise.reject().then(success, fooBarFailure2);:
      • then hooks up both the fulfillment handler (success) and the rejection handler (fooBarFailure2) to the promise from Promise.reject()
    • Since the promise is rejected, it uses a microtask to call the rejection handler attached to it.
    • "Before setting MicroTask." is logged.
    • The setTimeout queues its task
    • The queueMicrotask queues its microtask
    • "After setting MicroTask." is logged.
    • The task scheduling all this completes, so microtask processing starts
      • The first microtask is dealing with the rejection from Promise.reject().then(success).catch(fooBarFailure1);: That rejects the promise created by then, queuing a microtask to call the rejection handler on the promise then returned.
      • The second microtask is dealing with the rejection from Promise.reject().then(success, fooBarFailure2);: That rejects the promise, calling fooBarFailure2.
        • "Failure 2" is logged.
      • The third microtask is the one from queueMicrotask, which runs.
        • "From MicroTask!" is logged.
      • The microtask call to fooBarFailure1, scheduled above, runs.
        • "Failure 1" is logged.
    • The next task runs, calling the timer callback
      • "This Timeout was set before the MicroTask!" is logged.