Skip to content
academia.sh

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.nextTick queue 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, setImmediate runs in the same turn, setTimeout in the next. In the main module’s body, this order is not guaranteed.
  • The nextTick and 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.

Start typing to search.

↑↓ Esc navigate · open · close