
After my last job interview, I decided to dive into JavaScript theory. One of the most common topics is the event loop. In the previous post, I briefly described how the event loop works in browsers. But what about Node.js? It has its own event loop to handle multiple requests on a single thread. I ran into some confusion and want to share how I resolved it.
The Case
I had just started the Node.js tutorial on W3Schools and tried every example in practice. Everything went smoothly until the Event Loop article. As described there, the Event Loop phases run in this order:
- Timers:
setTimeout,setInterval - I/O Callbacks: Completed I/O operations
- Poll: Retrieve new I/O events
- Check:
setImmediatecallbacks - Close: Cleanup callbacks (like
socket.on('close'))
Between each phase, Node.js runs microtasks (Promises) and process.nextTick callbacks. nextTick has priority and runs before microtasks.
The following example should show how it works:
console.log('1. Start');
// Next tick queue
process.nextTick(() => console.log('2. Next tick'));
// Microtask queue (Promise)
Promise.resolve().then(() => console.log('3. Promise'));
// Timer phase
setTimeout(() => console.log('4. Timeout'), 0);
// Check phase
setImmediate(() => console.log('5. Immediate'));
console.log('6. End');
The expected output:
1. Start
6. End
2. Next tick
3. Promise
4. Timeout
5. Immediate
But imagine my surprise when I got a different result:
1. Start
6. End
3. Promise
2. Next tick
5. Immediate
4. Timeout
There were two discrepancies: Promise appeared before Next tick, and Timeout appeared after Immediate.
Was the article wrong? No – it just didn’t tell the whole truth.
The Truth
By default, Node.js uses the CommonJS standard, which is used by older projects, and there’s little reason to use it in a new one. I’m also accustomed to the import * from package syntax, so I set "type": "module" in my package.json to use ESM – honestly without thinking much of it. It’s just one of those things you do because everyone does.
In this case, though, the difference is significant: the code runs in a different order. So what can we do to seamlessly migrate from CommonJS to ESM while preserving the same execution order?
Migration
Replacement of nextTick
The Node.js documentation explains why the order differs between module systems:
Within Node.js, every time the “next tick queue” is drained, the microtask queue is drained immediately after.
So in CJS modules
process.nextTick()callbacks are always run beforequeueMicrotask()ones. However since ESM modules are processed already as part of the microtask queue, therequeueMicrotask()callbacks are always executed beforeprocess.nextTick()ones since Node.js is already in the process of draining the microtask queue.
In simple terms: ESM loads modules asynchronously, and microtasks always run first.
The documentation recommends using queueMicrotask() instead of process.nextTick().
console.log('1. Start');
// Microtask queue (queueMicrotask)
queueMicrotask(() => console.log('2. Microtask (Next tick)'));
// Microtask queue (Promise)
Promise.resolve().then(() => console.log('3. Promise'));
// ...
console.log('6. End');1. Start
6. End
2. Microtask (Next tick)
3. Promise
...
This behaves the same as Promise.resolve().then(). You can swap lines 2 and 3 and the order will change accordingly, since they share the same queue.
This means you can’t simply replace process.nextTick() with queueMicrotask() and expect it to always run first the way process.nextTick() did.
setImmediate() vs setTimeout()
One important thing to note: setTimeout() is non-deterministic at the top level. If you run the script multiple times in CommonJS, the results can vary.

The default delay is 1 ms (even if you set it to 0), and the execution order depends on how quickly the script loads. If loading takes more than 1 ms, the Timer phase runs first. If it takes less, the Event Loop skips the first phase because no Timer is scheduled within that window – meaning the loop can start with either the Timer or Poll phase.
This means the result shown in the article reflects the author’s specific machine and shouldn’t be treated as the only correct output.
But why doesn’t this happen in ESM? In ESM, setTimeout() becomes deterministic because the first iteration of the Event Loop always starts with the Poll phase — which is why the Timer always runs last.
For this reason, migrating from CommonJS to ESM actually makes your code more predictable and robust.
Conclusion
The W3Schools article doesn’t explain:
- The differences between CommonJS and ESM (which I think should be one of the first topics in the tutorial)
- Why
setTimeoutcan run in a different order
Despite this, I still appreciate the tutorial. If it had no gaps, I wouldn’t have done this research. Sometimes I think that fixing others’ mistakes is one of the best ways to learn.
Sources
- https://nodejs.org/api/process.html#when-to-use-queuemicrotask-vs-processnexttick
- https://stackoverflow.com/questions/77736974/precedence-of-setimmediate-and-settimeout-callbacks-in-commonjs-vs-esm
- https://github.com/nodejs/node/blob/574ad6d89dd9db2cb7a4b449118d4f8befab9b05/lib/internal/timers.js#L173
- https://github.com/nodejs/help/issues/392#issuecomment-305969168
- https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick#setimmediate-vs-settimeout
