Event Loop order
Compare synchronous code, nextTick, microtasks, timers, poll, and check in one live run.
Timeline
Understanding: Event Loop order
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
Imagine one cook and several priority shelves. Notes are written top to bottom, but that is not yet the cooking order. After finishing the current action, the cook chooses the next job by shelf and context. In a normal callback, Node checks nextTick, then microtasks, and then continues through Event Loop phases.
JavaScript in a Node process normally follows run-to-completion on one main thread: a running function is not interrupted by another callback. Call lines execute top to bottom, but nextTick, then, setTimeout, and setImmediate only register functions for later. Once the stack is empty, queue priority can outweigh visual source order.
Terms used in this experiment
Understand the words first, then the execution order.
Call Stack
The functions executing right now. No other callback starts JavaScript while this stack is busy.
Callback
A function the runtime invokes later after a timer, I/O completion, worker message, or another event.
Microtask
A high-priority Promise or queueMicrotask continuation, drained between callbacks and phases.
Phase
A stage of the libuv Event Loop. This lab focuses on timers, poll, and check.
Registration
The synchronous moment when runtime receives a callback and the conditions for running it later. Registration is not callback execution.
What happens step by step
Each step maps to an observable runtime state.
- 01Synchronous code runs
Lines are read top to bottom: console.log prints now, while the remaining calls register callbacks.
- 02The stack becomes empty
Node is now able to select deferred work.
- 03nextTick is drained
In this HTTP experiment, process.nextTick uses a special Node queue and precedes Promise.
- 04Microtasks are drained
Promise.then and queueMicrotask run, including new microtasks they enqueue.
- 05Phases continue
The loop moves to ready timers, poll I/O, and setImmediate in check.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
Source order is registration order
The nextTick line really executes before the Promise line, but their arrow-function bodies run later. Source order alone is not the final console.log order.
FIFO matters inside one queue
Two Promise.then callbacks registered in sequence normally keep that order. nextTick and Promise use different queues, so queue priority can overtake an earlier line.
Timer versus immediate needs location
In the main module, setTimeout(0) and setImmediate may swap. If both are created inside the same I/O callback, setImmediate runs first. Node 20 also changed where timers run relative to poll.
Top-level ESM is an exception
An ES module is itself evaluated as a microtask, so Promise can precede nextTick in a standalone ESM file. The lab registers them from an HTTP callback and shows nextTick → Promise.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
console.log('sync'); // Runs now
// These lines register callbacks from top to bottom:
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));
// Their bodies run later according to queue and phase rules.The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
import { readFile } from 'node:fs';
import { fileURLToPath } from 'node:url';
const packageJsonPath = fileURLToPath(
new URL('../package.json', import.meta.url),
);
const sleep = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
async function eventLoopOrder(emit) {
emit('call-stack', 'sync', 'Синхронный код начал выполняться');
const outerCallbacks = [];
const waitForOuter = new Promise((resolve) => {
let completed = 0;
const done = () => {
completed += 1;
if (completed === 4) resolve();
};
process.nextTick(() => {
outerCallbacks.push('process.nextTick');
emit('nextTick', 'callback', 'process.nextTick callback');
done();
});
Promise.resolve().then(() => {
outerCallbacks.push('Promise.then');
emit('microtasks', 'callback', 'Promise.then microtask');
done();
});
queueMicrotask(() => {
outerCallbacks.push('queueMicrotask');
emit('microtasks', 'callback', 'queueMicrotask callback');
done();
});
setTimeout(() => {
outerCallbacks.push('setTimeout(0)');
emit('timers', 'callback', 'setTimeout(0) callback');
done();
}, 0);
setImmediate(() => {
outerCallbacks.push('setImmediate');
emit('check', 'callback', 'setImmediate callback');
done();
});
});
emit(
'call-stack',
'schedule',
'Callbacks зарегистрированы; синхронный стек сейчас освободится',
);
await waitForOuter;
// Ждём обе макрозадачи. Эксперимент запускается из HTTP callback, поэтому
// здесь важен runtime-контекст, а не только порядок строк в сниппете.
while (
!outerCallbacks.includes('setTimeout(0)') ||
!outerCallbacks.includes('setImmediate')
) {
await sleep(1);
}
emit('result', 'result', `Первый раунд: ${outerCallbacks.join(' → ')}`);
emit('poll', 'schedule', 'Запускаем fs.readFile и переходим к I/O-раунду');
await new Promise((resolve, reject) => {
readFile(packageJsonPath, 'utf8', (error) => {
if (error) {
reject(error);
return;
}
emit('poll', 'callback', 'Callback fs.readFile: сейчас мы внутри poll-фазы');
const ioOrder = [];
let completed = 0;
const done = () => {
completed += 1;
if (completed === 4) {
emit('result', 'result', `Внутри I/O: ${ioOrder.join(' → ')}`);
resolve();
}
};
process.nextTick(() => {
ioOrder.push('nextTick');
emit('nextTick', 'callback', 'nextTick, созданный внутри I/O');
done();
});
Promise.resolve().then(() => {
ioOrder.push('Promise');
emit('microtasks', 'callback', 'Promise, созданный внутри I/O');
done();
});
setImmediate(() => {
ioOrder.push('setImmediate');
emit('check', 'callback', 'setImmediate, созданный внутри I/O');
done();
});
setTimeout(() => {
ioOrder.push('setTimeout');
emit('timers', 'callback', 'setTimeout(0), созданный внутри I/O');
done();
}, 0);
});
});
}The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.
Common misconceptions
The myth is on the left; the accurate model is on the right.
Node has one global event queue.
There are multiple queues and phases with different priority rules.
setTimeout(fn, 0) runs fn immediately.
Zero is a minimum delay; the callback still waits for its phase and a free stack.
Async code can interrupt current JavaScript.
A callback starts only after the current JavaScript finishes.
A callback from the higher source line must run first.
That only holds under compatible rules, such as one FIFO queue. Different queues apply their priorities and phase rules first.
process.nextTick is always before Promise.
That is the normal callback and CommonJS order. During top-level ESM evaluation, Promise/microtasks can get ahead.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Why does the Promise callback not run inside Promise.resolve()?
- What happens to a timer while the current function runs for five seconds?
- When does source order remain callback order, and when does a queue override it?