The Event Loop is the mechanism that lets one JavaScript thread coordinate many tasks. Synchronous code runs now, while timers, network and file operations, and other asynchronous work are registered and return callbacks when ready. The Event Loop repeatedly takes ready work from the appropriate queues and phases, allowing Node to handle many events without creating a separate JavaScript thread for every one of them.
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.
Main-thread Node JavaScript follows run-to-completion: another callback cannot interrupt a running section. After an ordinary callback, Node drains process.nextTick first and then the V8 microtask queue—Promise.then and queueMicrotask—before continuing or advancing the loop. A timer, I/O callback, or setImmediate can start only when ready and when the loop reaches its timers, poll, or check context.
Execution starts are chosen at boundaries
A source line registers a future function. That function can begin only after current JavaScript finishes and its queue context allows it.
- 01 · StackCurrent JavaScript finishes
A function or callback runs until return. No timer, Promise, or I/O callback can splice itself into the middle of synchronous work.
- 02 · CheckpointPriority work is checked
After an ordinary callback, Node drains nextTick and then Promise/queueMicrotask. Work added by them may also join this checkpoint.
- 03 · Event LoopThe current or next phase continues
Node takes another ready callback from the phase or moves through poll, check, and timers according to the current loop iteration.
Normally drains before V8 microtasks and can cause starvation when recursively refilled.
Runs at a checkpoint before phases continue; newly-added microtasks are drained too.
Delay is a minimum readiness threshold, not an exact callback start time.
Starts after the operation is ready, poll can process it, and the stack is free.
Waits for check and cannot interrupt a timer or I/O callback that is already running.
Sync really is first, and nextTick/microtasks receive a checkpoint. But timers, I/O, and immediates are not three positions in a global queue: their relative starts depend on phase, readiness, and registration location.
Callbacks in one already-ready FIFO queue are normally taken in insertion order. Do not extend that rule to different queues, different I/O sources, or merely equal delay values.
timer 1 → nextTick from timer 1 → Promise from timer 1 → timer 2 → setImmediate from timer 1A checkpoint occurs after timer 1, so priority work can appear between two callbacks of one phase. setImmediate waits for check and does not splice into the timers phase.
The Browser Event Loop and Node Event Loop are related, not identical
Both runtimes execute JavaScript and Promise jobs under ECMAScript rules, while the host determines where future work comes from. A browser integrates JavaScript with page events and rendering; Node integrates it with I/O and libuv phases.
One JavaScript callback does not preempt another callback
One JavaScript agent follows run-to-completion. A ready timer, network response, or message does not start immediately: the current stack must become free and the host must select that work.
- Promise reactions and queueMicrotask run as microtasks at a checkpoint.
- Long synchronous work delays both ordinary callbacks and microtasks in that agent.
- Parallel JavaScript requires another agent: a Web Worker, Worker Thread, or process.
Tasks, microtasks, and rendering opportunities
The main browser event loop coordinates page JavaScript, user events, and rendering. It is not a libuv phase loop or one global FIFO queue.
- Tasks
- Scripts, clicks, timers, and other host events queue tasks through different task sources. Ordering is preserved inside a source, while the browser selects among runnable queues.
- Microtasks
- After a task, the browser performs a microtask checkpoint. Promise.then, queueMicrotask, and MutationObserver can drain before the next task.
- Rendering
- At a rendering opportunity the browser may update style and layout, call requestAnimationFrame before paint, and render a frame. It need not render after every task, and background tabs may be throttled.
- Web Worker
- A worker has its own global scope and event loop, cannot directly access the DOM, and communicates through messages. Heavy work leaves the page stack free but still competes for CPU.
Node queues and libuv phases without rendering
Node connects V8 to file systems, networking, processes, and libuv. A server runtime has no DOM, layout, paint, or standard requestAnimationFrame.
- libuv phases
- The loop passes timers, pending callbacks, internal idle/prepare, poll, check, and close callbacks. setImmediate belongs to check; ready I/O callbacks are commonly processed around poll.
- nextTick
- process.nextTick is a Node queue, not a libuv phase or browser standard. After an ordinary callback it normally drains before V8 microtasks; recursive refilling can delay I/O.
- I/O and pool
- Sockets usually wait on OS readiness, while parts of fs, DNS, crypto, and zlib use the bounded libuv thread pool. A completed result still posts a callback to the JavaScript Event Loop.
- Worker Thread
- A Worker Thread creates a separate V8 isolate, stack, and Event Loop inside the process. It suits CPU-bound JavaScript; messages back to main become asynchronous callbacks.
The callback finishes first; then each host applies its own rules
Both snippets show run-to-completion and a microtask checkpoint. Their different APIs are host facilities, not features of the JavaScript language itself.
button.addEventListener('click', () => {
console.log('handler');
queueMicrotask(() => console.log('microtask'));
requestAnimationFrame(() => console.log('rAF'));
setTimeout(() => console.log('timer'), 0);
});The handler runs first, followed by the microtask after it returns. rAF runs before a selected frame is painted; the timer is a separate future task. There is no portable total order between rAF and the timer.
readFile(new URL(import.meta.url), () => {
console.log('I/O');
process.nextTick(() => console.log('nextTick'));
queueMicrotask(() => console.log('microtask'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timer'), 0);
});In this I/O context: I/O → nextTick → microtask → immediate → timer. The immediate reaches the next check phase; the new timer waits for a later timers context.
- Promise and queueMicrotask follow the shared JavaScript jobs model, while the host integrates their checkpoint.
- requestAnimationFrame describes browser rendering; setImmediate and process.nextTick are Node-specific APIs.
- Web Workers and Worker Threads solve a similar isolation problem through different APIs and environments.
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
Only now can another callback begin JavaScript. This is a selection boundary, not preemption of the current function.
- 03A priority checkpoint runs
After an ordinary callback Node drains process.nextTick and then Promise.then/queueMicrotask. Top-level ESM and an already-running microtask are separate contexts.
- 04Ready phase work is selected
Timers checks reached thresholds, poll handles ready I/O, and check runs setImmediate. There is no universal global FIFO between them.
- 05The rule repeats after a callback
Every completed callback is followed by another checkpoint. nextTick and Promise from timer 1 can therefore start before timer 2.
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 applies after context checks
Two Promise.then callbacks registered in sequence preserve their order. Two already-ready callbacks of one phase are also normally taken in queue order. This is not a rule for different phases, I/O sources, or readiness times.
Timers, immediates, and I/O are not a ladder
In the main module, setTimeout(0) and setImmediate may swap. If both are created inside one I/O callback, setImmediate runs before the new timer. I/O itself starts when its operation is ready and the loop can process its callback.
nextTick → Promise also needs context
After an ordinary callback and in CommonJS, nextTick precedes Promise microtasks. An ES module is evaluated as a microtask; scheduling at top-level ESM or inside another microtask can let Promise/queueMicrotask run before nextTick until control returns to Node.
Node version affects old diagrams
Since libuv 1.45 / Node 20, timers run only after poll instead of both before and after it. An old article’s diagram may therefore differ from modern Node.
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;
// Ждём timer и immediate. Начальный порядок зависит от того, из какого
// async-контекста вызывается сценарий, поэтому мы записываем наблюдение,
// а не подгоняем его под заученную последовательность.
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 live trace is application-level scenario instrumentation. Rows and timestamps are recorded when emit(...) actually runs inside callbacks, so the observed order belongs to a real execution. The scenario itself supplies source and lane labels: this is not a V8/libuv profiler or a direct view of their internal queues and phases.
Practical patterns worth keeping nearby
Compare the goal, code, and caveats instead of memorizing syntax without a model.
Microtasks between two timers
See the checkpoint after every callback, not only after the entire timers phase.
setTimeout(() => {
console.log('timer 1');
process.nextTick(() => console.log('nextTick from timer 1'));
Promise.resolve().then(() => console.log('Promise from timer 1'));
setImmediate(() => console.log('immediate from timer 1'));
}, 0);
setTimeout(() => console.log('timer 2'), 0);- If both timers are ready in one timers phase: timer 1 → nextTick → Promise → timer 2 → immediate.
- nextTick and Promise start after the timer 1 callback returns.
- setImmediate waits for check, so it does not interrupt timers processing.
Immediate and timer inside I/O
Pin down a context where the relative order is predictable.
import { readFile } from 'node:fs';
readFile(new URL(import.meta.url), () => {
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));
});
// Here: immediate → timer- The readFile callback is handled in an I/O/poll context.
- After poll, the loop reaches check, so the new immediate runs before the new zero-delay timer.
- Do not blindly transfer this result to a top-level main module.
How a learning mistake becomes an incident
A realistic service: the original code, observable failure, corrected implementation, and why the correction works.
An order event is published before the required audit record
A checkout endpoint stores an order, schedules event publishing with setImmediate, and then waits for the audit write. The author assumed that source-code order also guaranteed the order of every side effect.
After await, the current stack is released. While audit.write is waiting on I/O, the check phase may run setImmediate, so a downstream consumer can observe OrderCreated before the required audit record exists.
async function checkout(input) {
const order = await orders.insert(input);
setImmediate(() => {
broker.publish('OrderCreated', order);
});
await audit.write('order.created', order.id);
return order;
}Source order defines when work is registered, but the setImmediate callback and the await continuation use different scheduling mechanisms. There is no business ordering guarantee between them.
async function checkout(input) {
const order = await orders.insert(input);
await audit.write('order.created', order.id);
await broker.publish('OrderCreated', order);
return order;
}
// If INSERT and event creation must be atomic,
// write the order and an outbox row
// in the same database transaction.The required dependency is expressed with await. When the order and event must succeed together, a transactional outbox provides that guarantee instead of relying on Event Loop phases.
What the unfamiliar calls from both code samples actually do.
orders.insert(input)- An asynchronous repository method: writes the order to the database and returns a Promise with the created order.
setImmediate(callback)- Registers a callback for the Event Loop check phase. It does not mean “run strictly after the next source line.”
await promise- Pauses only the current async function. After settlement, its continuation is queued as a microtask.
broker.publish(...)- Sends an event to a message broker. A well-designed API returns a Promise so the caller can await acknowledgement.
transactional outbox- Stores the order and a future-event row in one DB transaction; a separate publisher sends the event later.
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?