Node.js is frequently celebrated for its ability to handle thousands of concurrent operations on a single thread. However, many developers write blocking asynchronous code that inadvertently locks this thread, leading to catastrophic lag. I wrote this guide to detail the inner mechanics of the libuv event loop to help developers write highly concurrent backend routers.
Since JavaScript is single-threaded, if a CPU-heavy task (like hashing a password or parsing a massive JSON payload) is executed on the main call stack, it blocks the thread. While that task is running, the server *cannot* respond to any other client requests, check database results, or emit WebSockets messages, resulting in severe timeouts.
Node.js uses a single-threaded event loop architecture combined with a multi-threaded C++ backend (via libuv) to run asynchronous operations. When an async query starts, libuv delegates it to the OS kernel or thread pool and immediately returns execution to the main stack. When the task completes, the callback is pushed to an event loop queue.
The event loop executes in 6 distinct phases in a continuous cycle:
1. Timers: Executes callbacks scheduled by setTimeout() and setInterval().
2. Pending Callbacks: Executes I/O callbacks deferred from the previous cycle.
3. Idle, Prepare: Used only internally by the system.
4. Poll: Retrieves new I/O events; server waits here if no timers are active.
5. Check: Executes callbacks scheduled by setImmediate().
6. Close Callbacks: Handles close events (e.g. socket.on('close')).
Between each phase, the event loop drains two Microtask Queues:
then/catch and resolved await triggers).The event loop execution cycle and phase transitions are illustrated below:
+---------------------------------------------+
| JavaScript Call Stack |
+---------------------------------------------+
|
( Non-Blocking Asynchronous )
|
v
+---------------------------------------------+
| libuv Thread Pool |
| (Handles CPU/File I/O tasks in background) |
+---------------------------------------------+
|
( Task Complete )
|
v
+=================== Event Loop Cycle ===================+
| |
| [ Phase 1: Timers ] -------> setTimeout/Interval |
| | |
| ( Run Microtask Queues: nextTick & Promises ) |
| | |
| [ Phase 2: Pending ] ------> Deferred I/O Callbacks |
| | |
| [ Phase 3: Poll ] ---------> Fetch New I/O Callbacks |
| | |
| [ Phase 4: Check ] --------> setImmediate |
| | |
| [ Phase 5: Close ] --------> socket.destroy() |
| |
+========================================================+Below is a code example demonstrating how different macro and micro tasks execute in the event loop:
const fs = require('fs');
console.log('1. Start Call Stack');
// Macro task 1: Timer (Phase 1)
setTimeout(() => {
console.log('2. setTimeout (Timer Phase)');
}, 0);
// Macro task 2: Check (Phase 4)
setImmediate(() => {
console.log('3. setImmediate (Check Phase)');
});
// Micro task 1: process.nextTick
process.nextTick(() => {
console.log('4. process.nextTick (Microtask)');
});
// Micro task 2: Resolved Promise
Promise.resolve().then(() => {
console.log('5. Promise then (Microtask)');
});
// Macro task 3: File I/O
fs.readFile(__filename, () => {
console.log('6. fs.readFile (Poll Phase)');
// Inside Poll Phase, setImmediate always runs BEFORE setTimeout!
setTimeout(() => console.log('7. Nested setTimeout'), 0);
setImmediate(() => console.log('8. Nested setImmediate'));
});
console.log('9. End Call Stack');Console Output Order:
1. 1. Start Call Stack
2. 9. End Call Stack
3. 4. process.nextTick (Microtask) (Runs immediately after call stack clear)
4. 5. Promise then (Microtask) (Drained right after nextTick)
5. 2. setTimeout (Timer Phase)
6. 3. setImmediate (Check Phase)
7. 6. fs.readFile (Poll Phase)
8. 8. Nested setImmediate (Inside Poll callback, setImmediate is queued in Check and runs first)
9. 7. Nested setTimeout (Timer runs in the next loop cycle)
worker_threads module) or background task queues (e.g. BullMQ fronted by Redis).process.nextTick does not run in a loop phase; it runs *immediately* when the current operation completes, before the event loop continues. Infinite recursive calls to process.nextTick will starve the event loop entirely, freezing I/O processes.fs.readFileSync inside routes blocks the event loop call stack. Always use fs.promises.readFile to ensure the file handle is delegated asynchronously to libuv's pool.I encountered event loop starvation in storefleet when handling real-time GPS coordinate broadcasts for 100+ trucks. The thread was overwhelmed by virtual DOM triggers. Resolving this required implementing a Redis-backed Socket room filter and throttling updates, keeping loop cycles under 15ms.
nextTick, resolved Promises) have priority and run immediately between phases.readFileSync, JSON.parse on huge strings) in server routes.