Node.js: Async: Event Loop
Last updated: 2026-08-26
1. Story: A Service Blocked by 200 ms
Bob’s API service typically responds in just 5 ms, but during peak hours, response times skyrocketed to 2,000 ms. Users kept complaining, and at first, he thought the database was slow, but adding indexes didn’t help. Later, using console.time to troubleshoot step by step, he discovered that sorting a large synchronous JSON array was taking 200 ms—this 200-ms operation was blocking the event loop, causing all queued requests to wait for it to finish. After understanding how the event loop works, Bob used setImmediate to break the sorting into smaller chunks and execute them in turn, reducing response times during peak hours to 50 ms.
You'll learn:
- Responsibilities and Flow of the 6 Phases of the Event Loop
- Priority of the microtask queue (process.nextTick / Promise.then)
- Scheduling of macro task queues (setTimeout / setImmediate / I/O)
- setTimeout vs setImmediate vs process.nextTick execution order
- Why
setImmediateTakes Precedence OversetTimeoutin an I/O Context - The Risks of Blocking the Event Loop and Strategies for Splitting It
2. The 6 Stages of the Event Loop
The Node.js event loop is driven by libuv and proceeds through six phases in sequence. Each loop cycle begins with "timers" and ends with "close callbacks," then returns to "timers" to start the next cycle.
flowchart TD
A["timers<br/>setTimeout / setInterval"] --> B["pending callbacks<br/>System-Level Callbacks"]
B --> C["idle, prepare<br/>For internal use only"]
C --> D["poll<br/>I/O callbacks / new I/O polling"]
D --> E["check<br/>setImmediate"]
E --> F["close callbacks<br/>socket.on('close')"]
F --> A
| Phase | Callback Type | Description |
|---|---|---|
| timers | setTimeout / setInterval | Executes the timer callback when it expires, subject to a minimum delay of 1 ms |
| pending callbacks | system-level callbacks | I/O callbacks deferred to the next loop (e.g., TCP errors) |
| idle, prepare | Internal use | Internal use by libuv; developers generally do not interact with it |
| poll | I/O callback | Checks for new I/O events and executes I/O-related callbacks; if there are no timers, the process will block at this stage |
| check | setImmediate | The setImmediate callback is executed at this stage |
| close callbacks | close events | Such as socket.on('close') close callbacks |
3. Microtasks and Macrotasks
(1) (3.1) Microtask Queue
Microtasks are executed in their entirety after the current phase ends and before the next phase begins. The microtask queue has two priority levels:
| Micro-task | Priority | Description |
|---|---|---|
| process.nextTick | Maximum | Prioritize clearing the nextTick queue after each phase ends |
| Promise.then / catch / finally | Second highest | Executed after the nextTick queue is cleared |
▶ Example: nextTick takes precedence over Promise.then
process.nextTick(() => {
console.log('nextTick 1');
});
Promise.resolve().then(() => {
console.log('promise.then 1');
});
process.nextTick(() => {
console.log('nextTick 2');
});
Promise.resolve().then(() => {
console.log('promise.then 2');
});
nextTick 1
nextTick 2
promise.then 1
promise.then 2
(2) (3.2) Macrotask Queue
Macrotasks are scheduled by each phase of the event loop, with each phase executing its corresponding macrotasks.
| Macro Task | Phase | Description |
|---|---|---|
| setTimeout / setInterval | timers | Minimum delay of 1 ms; enters the timers queue upon expiration |
| setImmediate | check | Executed during the "check" phase of the next iteration |
| I/O Callback | poll | Callback upon completion of I/O operations such as file reads/writes and network operations |
| close callbacks | close callbacks | Close event callbacks |
4. setTimeout vs setImmediate vs process.nextTick
▶ Example: The execution order of outer contexts is undefined
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
In a non-I/O context, the execution order of setTimeout and setImmediate is undefined—it depends on the system's process scheduling, and either one may execute first.
▶ Example: In an I/O context, setImmediate is always executed first
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
setImmediate
setTimeout
I/O callbacks are executed during the poll phase; once they are complete, the process moves to the check phase (setImmediate), and only then proceeds to the next round of the timers phase (setTimeout).
▶ Example: process.nextTick always comes first
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
process.nextTick(() => {
console.log('nextTick');
});
nextTick
setTimeout (or setImmediate, depends on scheduling)
setImmediate
| Feature | setTimeout(fn, 0) | setImmediate | process.nextTick |
|---|---|---|---|
| Queue | Macro Tasks (timers) | Macro Tasks (check) | Microtasks (highest priority) |
| Execution Phase | timers | check | Immediately after the current phase ends |
| Minimum Latency | 1 ms | None | None |
| I/O Context Order | After setImmediate | Before setTimeout | First |
| Outer Context Order | Undefined | Undefined | Earliest |
5. Comparison of Microtasks vs. Macrotasks
| Dimension | Micro-task | Macro-task |
|---|---|---|
| Representative | process.nextTick, Promise.then | setTimeout, setImmediate, I/O callbacks |
| Execution Timing | After the current phase ends and before the next phase begins | During the corresponding phase of the event loop |
| Execution Strategy | Clear All at Once | Execute One Per Phase, Then Check the Microtasks |
| Nested | Microtasks generated during execution are also cleared in this round | Pushed to a later stage for execution |
| Priority | Higher than all macro tasks | Lower than microtasks |
▶ Example: Micro-tasks interspersed among macro-tasks
setTimeout(() => {
console.log('timeout 1');
Promise.resolve().then(() => {
console.log('promise between 1 and 2');
});
}, 0);
setTimeout(() => {
console.log('timeout 2');
}, 0);
timeout 1
promise between 1 and 2
timeout 2
After each macro-task is completed, the micro-task queue is cleared before the next macro-task is executed.
6. The Dangers of Blocking the Event Loop and How to Avoid Them
▶ Example: Blocking the Event Loop
const http = require('http');
const server = http.createServer((req, res) => {
const start = Date.now();
while (Date.now() - start < 200) {}
res.end('done');
});
server.listen(3000, () => {
console.log('Server running on port 3000');
});
Each request blocks the event loop for 200 ms, during which time no other requests can be processed.
▶ Example: Breaking Down Long-Running Tasks with setImmediate
function chunkedSort(arr, compareFn, callback) {
const CHUNK = 1000;
let offset = 0;
function sortChunk() {
const end = Math.min(offset + CHUNK, arr.length);
for (let i = offset; i < end; i++) {
for (let j = i + 1; j < end; j++) {
if (compareFn(arr[i], arr[j]) > 0) {
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
}
offset = end;
if (offset < arr.length) {
setImmediate(sortChunk);
} else {
callback(arr);
}
}
setImmediate(sortChunk);
}
chunkedSort(
[5, 3, 8, 1, 9, 2, 7, 4, 6],
(a, b) => a - b,
(result) => {
console.log('Sorted:', result);
}
);
| Dimension | Blocking Code | Non-blocking Code |
|---|---|---|
| Calculation Method | Complete in a single synchronous loop | Split into small chunks and execute sequentially using setImmediate |
| Event loop | Stuck; other requests are on hold | Control is yielded between slices; other callbacks can execute |
| Response Time | Cumulative Delay | Variation in Delay (User-Acceptable) |
| CPU Utilization | Single-core 100% (spikes) | Distributed usage, allowing time for I/O |
| Use Cases | Very Short Computations | Long Computations, High-Concurrency Services |
7. Comprehensive Example: Script to Verify the Execution Order of the Event Loop
The following script verifies the complete execution order of setTimeout → process.nextTick → setImmediate → Promise.then → I/O callback.
const fs = require('fs');
console.log('--- Script start ---');
setTimeout(() => {
console.log('1. setTimeout (macrotask - timers)');
}, 0);
setImmediate(() => {
console.log('2. setImmediate (macrotask - check)');
});
process.nextTick(() => {
console.log('3. process.nextTick (microtask - highest)');
});
Promise.resolve().then(() => {
console.log('4. Promise.then (microtask)');
});
fs.readFile(__filename, () => {
console.log('5. I/O callback (poll phase)');
setTimeout(() => {
console.log('6. inner setTimeout');
}, 0);
setImmediate(() => {
console.log('7. inner setImmediate');
});
process.nextTick(() => {
console.log('8. inner nextTick');
});
Promise.resolve().then(() => {
console.log('9. inner Promise.then');
});
});
console.log('--- Script end ---');
--- Script start ---
--- Script end ---
3. process.nextTick (microtask - highest)
4. Promise.then (microtask)
1. setTimeout (macrotask - timers)
2. setImmediate (macrotask - check)
5. I/O callback (poll phase)
8. inner nextTick
9. inner Promise.then
7. inner setImmediate
6. inner setTimeout
Analysis of Execution Order:
- The synchronous code runs first, outputting "Script start" and "Script end"
- Clear microtasks after the current phase ends: nextTick → Promise.then
- Enter the "timers" phase: setTimeout
- Enter the check phase: setImmediate
- Execute the callback during the poll phase after I/O is complete
- Clear the microtask after the I/O callback completes: inner nextTick → inner Promise.then
- When the poll ends, enter the check: inner setImmediate
- Next round of timers: inner setTimeout
❓ FAQ
setTimeout(fn, 0) really execute after 0 ms?process.nextTick or Promise.then?process.nextTick runs first. nextTick has the highest priority in the microtask queue, and the nextTick queue is always cleared before the Promise queue.setImmediate execute before setTimeout in an I/O callback?poll phase. Once that phase ends, the process moves directly to the check phase (setImmediate) before proceeding to the next round of the timers phase (setTimeout). Therefore, in an I/O context, setImmediate always executes before setTimeout.setImmediate; use worker threads to move CPU-intensive tasks to separate threads; use asynchronous I/O instead of synchronous I/O; and use streams to process large datasets in chunks.process.nextTick be called recursively indefinitely?process.maxTickDepth; recursive calls to nextTick will prevent the event loop from entering the next phase, leading to I/O starvation. You should use setImmediate instead of recursive nextTick calls.📖 Summary
- Story: The Core Concepts and Usage of a Service Blocked by 200 ms
- Key Concepts and Usage of the 6 Phases of the Event Loop
- Core Concepts and Usage of Micro-Tasks and Macro-Tasks
- Key Concepts and Usage of setTimeout, setImmediate, and process.nextTick
- Key Concepts and Usage of Micro-Tasks vs. Macro-Tasks
- The Risks of Blocking the Event Loop, Key Concepts for Avoiding It, and How to Do It
- Comprehensive Example: Verifying the Execution Order of the Event Loop—Core Concepts and Usage of Scripts
📝 Exercises
- Complete all the code examples in this lesson and make sure each one runs correctly.
- Modify the comprehensive example and add your own extensions
- Review the official documentation, identify 1–2 APIs not covered in this lesson, and write test code for them.
- Reflection: How would you apply what you’ve learned in this lesson to a real-world project?
- Try to combine what you’ve learned in this lesson with material from previous lessons to build a small project.