---
title: 'Event Loop Phases'
source: 'https://academia.sh/en/courses/nodejs/event-loop-phases'
course: 'The Node.js Runtime'
language: en
updated: '2026-08-17T18:09:52+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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:

```js
// 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');
});
```

```sh
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:

```js
// race.mjs
setTimeout(() => console.log('timers'), 0);
setImmediate(() => console.log('check'));
```

The two outputs below come from consecutive runs of the same file:

```sh
node race.mjs
```

```
check
timers
```

```sh
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:

```js
// micro.cjs
console.log('1 sync');
process.nextTick(() => console.log('2 nextTick'));
Promise.resolve().then(() => console.log('3 microtask'));
console.log('4 sync end');
```

```sh
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:

```sh
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.

```js
// 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');
```

```sh
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.
