NodeJS — an interesting case where Microtasks runs before process.nextTick

Nilesh Jha
3 min readAug 29, 2023

--

Promise.resolve().then(() => {
console.log('foo');
});

process.nextTick(() => {
console.log('bar');
});

Pretty straight forward to answer this — nextTick takes the higher priority and hence barcomes before foo. But what if i were to tell you — not always? See for yourself.

The same program ran twice, with different behaviour. So what’s going on?

I’ll give you a hint: the latter one is run as ESM while the former one as commonJS.

Introduction

In NodeJS, there are different types of callback queues being maintained during runtime by nodeJS that aid in providing asynchronous behaviour to the single-threaded javascript engine. This post is meant to target the peculiar behavious of microtask queues and nextTick queues.

To set the premise, let’s understand what these queues are in brief (feel free to skip this section if you know about these):

  1. Microtask Queues:
    In NodeJS, the microtask queue, also known as the “promises microtask queue” is a part of the event loop responsible for handling asynchronous tasks with higher priority than regular tasks (macrotasks).
    When a promise changes it’s pending state, its associated callbacks are queued in the microtask queue. This ensures that promise-related callbacks are executed as soon as possible, often right after the current function finishes executing, and before any other macrotasks are processed. It helps prevent long delays caused by waiting for the event loop to cycle through all the macrotasks before handling critical promise-related actions.
    This behaviour is quiet identical with how browsers runtime queues are implemented.
  2. nextTick Queues:
    Along with macro and micro task queues, NodeJS also provides a nextTick queue via process.nextTick() method.
    nextTick queue is a special queue that holds functions scheduled to execute immediately after the current operation but before the event loop continues its iteration. This queue has the highest priority among asynchronous tasks in the event loop.

Recursive asynchronous task phase

This is a common pattern in asynchronous programming environments where asynchronous tasks are scheduled within other asynchronous tasks, creating a recursive chain of execution.

For example, in the context of the Node.js event loop, we might have:

  1. A main execution context that starts with some synchronous code.
  2. Asynchronous operations (like I/O operations or timers) are initiated, and their associated callbacks are added to appropriate queues (microtask queue, macrotask queue, etc.).
  3. As these callbacks are executed, they might, in turn, trigger more asynchronous operations or schedule more callbacks to run.

The thing is, once microtask queue or nextTick queue starts executing, They set their phase and are emptied “live” until completion, and thus any new queued callback will get executed before any other queue is visited.

Promise.resolve().then(() => {
console.log('microtask 1');
Promise.resolve().then(() => {
console.log('microtask 2');
})
process.nextTick(() => {
console.log('nextTick in microtask phase');
})
});

process.nextTick(() => {
console.log('nextTick in poll phase');
})

// nextTick in poll phase
// microtask 1
// microtask 2
// nextTick in microtask phase

At the end of execution context, there are two tasks in queue, Promise’s .then() callback and process’ nextTickcallback.

Since nextTick takes the precedence in Poll phase, it is executed first but once the execution goes to microtask phase, It stays there to finish the new microtask queue before switching to nextTick queue.

This implementation detail is what’s at play here.

Coming back to the main topic of this post —

The reason why that happens as how that happens is because, when ran as ESM the script is already in microtask phase. so new microtask queued from there will get executed before a nextTick callback. This is as per the mentioned “emptied live until completion” behaviour.

In node, ESM module scripts are actually evaluated from an async/await function:

async run() {
await this.instantiate();
const timeout = -1;
const breakOnSigint = false;
try {
await this.module.evaluate(timeout, breakOnSigint);

So this means that when our script is run, we’re already inside the microtask phase, and thus new microtask that get queued from there will get executed first.

When ran as CommonJS though, we’re still in the poll phase & everything until the evaluation phase is synchronous — and hence nextTick tasks are executed first as per the norm.

--

--