The Node.js Event Loop

Phases, Microtasks, and Execution Order

Photo by Tine Ivanič on Unsplash

If you have been writing Node.js code for a while, you have likely encountered situations where callbacks fire in an unexpected order, or a Promise resolution jumps ahead of a setTimeout. These surprises almost always trace back to the same root cause: a misunderstanding of how the event loop processes work. The event loop is the engine that makes non-blocking I/O possible in Node.js, and understanding its internals is one of the most valuable investments you can make as a backend JavaScript developer.

In this article, we will break down the event loop phase by phase, explore where microtasks fit in, and run concrete code examples so you can predict execution order with confidence. Everything here uses the built-in Node.js runtime no frameworks or third-party packages required.

The Six Phases of the Event Loop

Under the hood, Node.js delegates asynchronous operations to libuv, a C library that provides a multi-phase event loop. Each iteration of the loop often called a “tick” moves through these phases in a fixed order: timers, pending callbacks, idle/prepare, poll, check, and close callbacks.

The timers phase executes callbacks scheduled by setTimeout and setInterval whose threshold has elapsed. The pending callbacks phase handles certain system-level callbacks such as TCP error notifications. The idle and prepare phases are used internally by Node.js and are not directly accessible from user code. The poll phase retrieves new I/O events and executes their callbacks this is where most of your file system reads, network responses, and similar operations get processed. The check phase runs callbacks registered via setImmediate. Finally, the close callbacks phase fires callbacks for closed handles, such as when a socket emits its close event.

Let us see this in action with a simple script that demonstrates the ordering of timers, setImmediate, and I/O callbacks.

const fs = require('node:fs');

// Timer phase
setTimeout(() => {
console.log('1 - setTimeout');
}, 0);

// Check phase
setImmediate(() => {
console.log('2 - setImmediate');
});

// Poll phase (I/O callback)
fs.readFile(__filename, () => {
console.log('3 - I/O callback');

setTimeout(() => {
console.log('4 - setTimeout inside I/O');
}, 0);

setImmediate(() => {
console.log('5 - setImmediate inside I/O');
});
});

console.log('6 - synchronous');

Expected output:

6 - synchronous
1 - setTimeout
2 - setImmediate
3 - I/O callback
5 - setImmediate inside I/O
4 - setTimeout inside I/O

The synchronous log always runs first because it executes during the initial evaluation of the script, before the event loop begins processing. At the top level, setTimeout with a delay of 0 and setImmediate have a non-deterministic order which one fires first depends on process performance and how quickly the loop starts. However, inside an I/O callback, setImmediate is always guaranteed to run before setTimeout with a 0 ms delay, because after an I/O callback completes, the loop moves directly to the check phase before circling back to the timers phase.

process.nextTick and Promises

Microtasks are not part of the six event loop phases. Instead, they live in their own queue and are processed between every phase transition and after every callback. Node.js has two microtask queues: the nextTick queue and the Promise microtask queue. The nextTick queue always drains before the Promise queue.

This means that no matter which phase the event loop is in, any call to process.nextTick or any resolved Promise will have its callback executed before the loop moves on to the next phase. This behavior is extremely powerful, but it can also be a source of bugs if you accidentally starve the event loop by recursively scheduling nextTick callbacks.

Here is an example that shows exactly how microtasks interleave with the timer and check phases.

setTimeout(() => {
console.log('1 - setTimeout');
}, 0);

setImmediate(() => {
console.log('2 - setImmediate');
});

process.nextTick(() => {
console.log('3 - nextTick');
});

Promise.resolve().then(() => {
console.log('4 - Promise.then');
});

console.log('5 - synchronous');

Expected output:

5 - synchronous
3 - nextTick
4 - Promise.then
1 - setTimeout
2 - setImmediate

The synchronous code runs first as part of the main module execution. Next, the microtask queues drain: process.nextTick fires before Promise.then because the nextTick queue has higher priority. Only after all microtasks have been processed does the event loop enter its first phase. The order of setTimeout and setImmediate at the top level is technically non-deterministic, but nextTick and Promise callbacks will always precede both.

Starvation and the nextTick Trap

Because the microtask queues drain completely before the event loop advances, a recursive process.nextTick call can starve I/O and timer callbacks indefinitely. This is a common mistake that leads to applications becoming unresponsive.

Consider this example that demonstrates the starvation problem and its solution.

// WARNING: This will starve the event loop!
function recursiveNextTick() {
process.nextTick(() => {
console.log('nextTick called');
recursiveNextTick();
});
}

// This setTimeout will NEVER fire
setTimeout(() => {
console.log('This will never print');
}, 100);

recursiveNextTick();

Expected output:

nextTick called
nextTick called
nextTick called
... (repeats indefinitely, setTimeout never fires)

The fix is to replace process.nextTick with setImmediate when you need recursion. Because setImmediate runs during the check phase rather than as a microtask, it gives the event loop a chance to process other phases between invocations.

function safeRecursion() {
setImmediate(() => {
console.log('setImmediate called');
safeRecursion();
});
}

setTimeout(() => {
console.log('setTimeout fires normally');
process.exit();
}, 100);

safeRecursion();

Expected output:

setImmediate called
setImmediate called
setImmediate called
... (continues until 100ms elapses)
setTimeout fires normally

With setImmediate, the timer callback fires after roughly 100 milliseconds because the event loop is free to visit the timers phase on each iteration. This is a crucial distinction that directly affects the reliability of your Node.js applications.

Putting It All Together: A Complete Ordering Example

Now that you understand each piece individually, let us combine everything into a single comprehensive example. This script schedules work across multiple phases and microtask queues so you can trace the entire execution path.

const fs = require('node:fs');

console.log('A - synchronous start');

setTimeout(() => {
console.log('B - setTimeout 0ms');

process.nextTick(() => {
console.log('C - nextTick inside setTimeout');
});

Promise.resolve().then(() => {
console.log('D - Promise inside setTimeout');
});
}, 0);

setImmediate(() => {
console.log('E - setImmediate');
});

fs.readFile(__filename, () => {
console.log('F - I/O callback');

setImmediate(() => {
console.log('G - setImmediate inside I/O');
});

setTimeout(() => {
console.log('H - setTimeout inside I/O');
}, 0);
});

process.nextTick(() => {
console.log('I - nextTick');
});

Promise.resolve().then(() => {
console.log('J - Promise.then');
});

console.log('K - synchronous end');

Expected output:

A - synchronous start
K - synchronous end
I - nextTick
J - Promise.then
B - setTimeout 0ms
C - nextTick inside setTimeout
D - Promise inside setTimeout
E - setImmediate
F - I/O callback
G - setImmediate inside I/O
H - setTimeout inside I/O

Let us trace through the output step by step.

First, the two synchronous console.log calls execute (A and K). Then the microtask queues drain: process.nextTick (I) runs before the Promise callback (J). Now the event loop begins its phases. The timers phase fires the setTimeout callback (B), and before moving on, the microtask queues drain again, executing the nextTick (C) and Promise (D) that were scheduled inside that timer callback. The check phase runs the setImmediate (E). Later, when the file read completes, the poll phase fires the I/O callback (F). From inside the I/O callback, setImmediate (G) is guaranteed to run before the setTimeout (H), as we discussed in section one.

The Node.js event loop is not a black box. It follows a well-defined sequence of phases, and microtasks occupy a special position between those phases. Here are the key takeaways that will help you write more predictable asynchronous code.

Synchronous code always runs to completion before any asynchronous callbacks. Microtasks, including process.nextTick and resolved Promises, execute between every phase of the event loop and after every individual callback. The nextTick queue has higher priority than the Promise microtask queue. At the top level, the order between setTimeout with 0ms delay and setImmediate is non-deterministic. However, inside an I/O callback, setImmediate always runs before a 0ms setTimeout. Recursive process.nextTick calls will starve the event loop; prefer setImmediate for recursive asynchronous patterns.

Understanding these rules transforms the event loop from a source of confusion into a predictable tool. The next time you encounter unexpected callback ordering in your Node.js application, come back to these examples and trace through the phases. Once the mental model clicks, debugging asynchronous issues becomes significantly easier.


The Node.js Event Loop was originally published in Server-Side JavaScript on Medium, where people are continuing the conversation by highlighting and responding to this story.

Scroll to Top