Lesson 02 / 20
Event Loop Phases
The loop's phase order, the difference between the timers and check phases, when the two microtask queues drain, and the measurable cost of blocking work.
Contents
The previous lesson read the file with readFileSync: when the call returned, the
data was ready, but until it returned, no other work in the process advanced. In a
single-threaded server, this behavior is unacceptable; every millisecond spent waiting
delays every request that arrives at the same time.
This lesson’s question: what exactly does the mechanism that replaces waiting do? The Asynchronous JavaScript and the Runtime course introduced the event loop as a cycle that takes a task from a queue when the call stack empties. In the server runtime, that cycle’s inside becomes visible: not one queue, but several phases visited in order.
The Loop’s Phases
The process enters the event loop after running the main module’s body start to finish. Every turn of the loop passes through several phases in a fixed order. Each phase has its own callback queue; entering a phase drains that queue, then the loop moves to the next phase.
| Phase | Callbacks it runs |
|---|---|
| timers | expired setTimeout and setInterval callbacks |
| pending callbacks | system-level callbacks deferred from the previous turn |
| poll | callbacks for completed input/output operations |
| check | callbacks set up with setImmediate |
| close callbacks | callbacks for close events |
The poll phase is where the loop actually waits. If there is no other work to run, the process stops here and asks the operating system “is there input/output ready?” This wait is not a CPU-busy wait; the kernel wakes the process when a socket becomes readable or a file read finishes.
This is how “waiting” separates from “blocking.” The process can be asleep in the poll phase while waiting for a request’s response; if data arrives from another connection in the meantime, it wakes up and runs that connection’s callback.
The Order Between the Timers and Check Phases
If something is added to a phase’s queue while inside a phase that comes after it, the callback runs in the same turn; adding to an earlier phase’s queue makes it wait for the next turn. This rule produces a definite order when viewed from inside an input/output callback:
// phases.mjs import { readFile } from 'node:fs'; console.log('sync body'); readFile('measurements.ndjson', () => { console.log('poll: file read'); process.nextTick(() => console.log(' nextTick queue')); Promise.resolve().then(() => console.log(' promise microtask')); setTimeout(() => console.log(' timers phase'), 0); setImmediate(() => console.log(' check phase')); console.log('poll: callback body done'); });
node phases.mjs
sync body poll: file read poll: callback body done nextTick queue promise microtask check phase timers phase
The order is not chance. The callback ran in the poll phase; because the check phase
comes after poll, setImmediate ran in the same turn. The timers phase comes
before poll, so setTimeout waited for the next turn. This relationship always
holds inside an input/output callback.
When the same two calls are made from the main module’s body, the order is not guaranteed:
// race.mjs setTimeout(() => console.log('timers'), 0); setImmediate(() => console.log('check'));
The two outputs below come from consecutive runs of the same file:
node race.mjs
check timers
node race.mjs
timers check
The reason is the time elapsed before the loop’s first turn is entered. The zero
given to setTimeout gets rounded up to at least one millisecond by the runtime. If
the main module runs in under a millisecond, the timer has not expired yet when the
loop reaches the timers phase, and the callback waits for the next turn; if it takes
longer, the timer has expired and the callback runs on the first turn. Since the time
depends on machine load, the result varies from run to run. The rule that follows:
program logic is never anchored to the relative order of callbacks belonging to two
different phases.
Two Microtask Queues
Between phases and after every callback, two extra queues get drained. These do not belong to any phase; they are handled at every checkpoint between phases.
- The
process.nextTickqueue is specific to the runtime. - The promise microtask queue is defined in the language standard and exists in the browser too.
At a checkpoint, the nextTick queue drains first, then the promise queue. This
order shows in the output above: after the callback body finished, nextTick queue
printed first, then promise microtask.
There is a case where this order looks different at the module’s top level:
// micro.cjs console.log('1 sync'); process.nextTick(() => console.log('2 nextTick')); Promise.resolve().then(() => console.log('3 microtask')); console.log('4 sync end');
node micro.cjs
1 sync 4 sync end 2 nextTick 3 microtask
When the same lines are saved with a .mjs extension, the order reverses:
node micro.mjs
1 sync 4 sync end 3 microtask 2 nextTick
The reason is how ES modules get evaluated: the module body itself runs as a promise
job. When the body finishes, the process is still draining the promise queue, so a
newly added promise job runs in the same drain pass; the nextTick queue, though,
waits for the checkpoint at the turn’s end. The lesson: the two queues’ relative
order is not a contract, it depends on where the code runs. Program correctness must
not rest on this order.
The process.nextTick queue has a hazard of its own. This queue is drained
entirely before moving to the next phase; if a callback adds a new one to the
queue every time it runs, the loop can never advance and the process stops
responding.
The Measured Cost of Blocking
The phase model has exactly one weak point: while a phase’s callback is running, nothing else can run. Long-running synchronous work stops the entire loop.
// blocking.mjs const start = process.hrtime.bigint(); setTimeout(() => { const elapsed = Number(process.hrtime.bigint() - start) / 1e6; console.log(`timer set for 10 ms fired after ${elapsed.toFixed(0)} ms`); }, 10); // Synchronous work that deliberately keeps the event loop busy const deadline = Date.now() + 200; while (Date.now() < deadline) { /* empty loop */ } console.log('sync work done');
node blocking.mjs
sync work done timer set for 10 ms fired after 201 ms
The printed duration varies by a few milliseconds depending on machine and load; what matters is the order of magnitude. The 10 ms given to the timer is not a promise but a lower bound: the callback runs once its time has expired and the loop reaches the timers phase. Since the loop was kept busy for 200 ms, the delay came out close to that.
The same measurement means this on a server: a handler doing 200 ms of synchronous work per request answers at most five requests a second, and the sixth request waits in queue. This limit comes not from the hardware but from how the work is carried out.
Two rules follow. First, synchronous input/output interfaces (calls ending in
...Sync) are not used while handling a server request; work done before the loop is
running, like reading configuration at startup, is the exception. Second, long
CPU-bound computations have to be moved off the main thread; how to do that is taken
up in the Cluster and Worker Threads lesson.
Summary
- Every turn of the event loop passes through phases in a fixed order: timers, pending callbacks, poll, check, close callbacks. Each phase has its own queue.
- The poll phase is the wait point that does not keep the CPU busy; the kernel wakes the process when input/output is ready.
- Inside an input/output callback,
setImmediateruns in the same turn,setTimeoutin the next. In the main module’s body, this order is not guaranteed. - The
nextTickand promise queues drain at checkpoints between phases; their relative order depends on where the code runs and is never used as a basis for program logic. - Timer delay is a lower bound; synchronous work that keeps the loop busy directly extends the delay by its own duration.
Next Step
In this lesson, the process was treated as the container that runs the loop. The process itself is also an object exposed to the program: you read and write, through that object, what arguments it started with, which environment variables it inherited, where its standard streams connect, and what exit code it will end with. The next lesson takes up the process object and builds the measurement collector’s first command-line behavior.
To keep your progress and take notes, Log in
My notes
Log in to take notes.