Node.js Event Loop & Asynchronous Execution — Clearly Explained
Understanding the Node.js event loop is the difference between “it works” and “I truly understand what’s happening.”
This blog builds a clear mental model — no myths, no hand-waving.
1. The Big Picture: What Actually Runs Your Code?
Node.js has two core pieces:
V8 (JavaScript Engine)
Executes JavaScript
Manages:
- Call stack
- Heap
- Promise microtasks
Runs code to completion
Single-threaded
libuv (C library)
Implements the event loop
Handles:
- Timers
- I/O polling
- Thread pool
Selects which callback runs next
Never executes JS
V8 executes JavaScript. libuv schedules callbacks.
2. The Call Stack — Where JS Actually Runs
All JavaScript runs on a single thread.
console.log("start");
while(true) {}
console.log("end");
If that loop runs forever:
- Nothing else executes.
- No timers.
- No I/O.
- No promises.
Because:
JavaScript runs to completion. Nothing interrupts it.
3. Top-Level Code vs Event Loop
This is widely misunderstood.
console.log("start");
setTimeout(() => console.log("timeout"));
console.log("end");
What happens?
- Script loads.
- All synchronous code runs immediately.
setTimeoutonly registers a callback.- Only when the stack is empty does the event loop start iterating.
🔑 Important Insight
The event loop runs callbacks. Top-level code is NOT a callback.
4. Microtasks — The Highest Priority Work
Node.js has two microtask queues:
process.nextTick()(Node-specific, highest priority)- Promise microtasks (
.then,await)
Drain Rule (Critical)
After every callback execution:
- Drain
process.nextTick - Drain Promise microtasks
- Then continue the event loop
Visual Flow
flowchart TD
A[libuv selects callback]
B[V8 executes callback]
C[Drain microtasks]
D[Return to libuv]
A --> B --> C --> D --> Alibuv chooses. V8 executes. Microtasks drain. Repeat forever.
Consequences
- Microtasks run before timers and I/O.
- Too many microtasks can starve the event loop.
process.nextTickcan freeze everything if abused.
5. async / await — What It REALLY Does
await something();
console.log("after");
This means:
- Pause function
- Schedule the rest as a Promise microtask
awaitnever blocks. It splits execution.
Each await = One Microtask Hop
Multiple awaits = multiple microtask drains.
Heavy chaining can delay timers and I/O.
6. Event Loop Phases (Node-Specific)
Node’s event loop has fixed phases:
flowchart LR
T[Timers]
P1[Pending Callbacks]
I[Idle / Prepare]
POLL[Poll]
C[Check]
CL[Close Callbacks]
T --> P1 --> I --> POLL --> C --> CL --> T6.1 Timers Phase
setTimeoutsetInterval- Delay is minimum, not exact
6.2 Pending Callbacks
- System-level callbacks
- Mostly I/O error-related
6.3 Idle / Prepare
- Internal
- No user JS runs
- Prepares for Poll (decides how long the Poll phase can block based on nearest timer deadline)
6.4 Poll (The Heart of Node.js)
Handles:
I/O callbacks (fs, net, http)
Decides:
- Block?
- Continue?
- Move to next phase?
Poll balances latency vs throughput.
6.5 Check Phase
- Runs
setImmediate - Always after Poll
6.6 Close Callbacks
- Cleanup handlers
- Socket close events
7. setImmediate vs setTimeout(0)
setTimeout(0)→ Timers phasesetImmediate()→ Check phase
Inside I/O:
Poll finishes → Check runs next
So setImmediate often runs first.
8. Offloading Work: Kernel vs Thread Pool
Node uses two async mechanisms.
A. OS-Level Async I/O (Best Case)
Used for:
- TCP
- HTTP
- UDP
Mechanism:
- Registers descriptors via
epoll/kqueue - No threads blocked
- OS signals readiness
B. libuv Thread Pool
Used for:
fscryptozlib- DNS lookup
Default size: 4 threads Configurable via:
UV_THREADPOOL_SIZE=8 node app.js
Visual Flow
flowchart TD
JS[V8 JS Code]
API[Node API call]
UV[libuv]
OS[OS Kernel epoll]
TP[Thread Pool]
POLL[Poll Phase]
CB[Callback Execution]
JS --> API --> UV
UV --> OS
UV --> TP
OS --> POLL
TP --> POLL
POLL --> CBPerformance Insight
If you see:
- High CPU
- Slow I/O
- Low JS usage
You are likely thread pool bound, not event-loop bound.
9. Starvation Scenarios
Microtask Starvation
function loop() {
Promise.resolve().then(loop);
}
loop();
Event loop never progresses.
nextTick Starvation
Even worse:
function loop() {
process.nextTick(loop);
}
loop();
I/O completely freezes.
10. One-Page Mental Model
flowchart TD
STACK[JS Call Stack]
NT[nextTick queue]
PM[Promise microtasks]
EL[Event Loop selects one callback]
STACK --> NT --> PM --> EL --> STACKRepeat forever.
12. Code Challenge #1
setTimeout(() => console.log("timeout"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
Output:
sync
nextTick
promise
timeout
Why?
- Sync runs first.
- nextTick drains.
- Promise microtask drains.
- Timers phase runs.
13. Code Challenge #2
setImmediate(() => console.log("immediate"));
fs.readFile(__filename, () => {
console.log("file");
});
Output:
file
immediate
Why?
fs.readFilecompletes.- Callback queued in Poll.
- Poll runs → prints "file".
- Check phase runs → prints "immediate".
14. Node.js vs Browser Architecture
| Aspect | Node.js | Browser |
|---|---|---|
| Primary role | Server runtime | UI runtime |
| Async provider | libuv | Web APIs |
| Event loop | Multi-phase | Task-based |
| File system | ✅ Yes | ❌ No |
| DOM | ❌ No | ✅ Yes |
| Rendering | ❌ None | ✅ Core feature |
JavaScript is the same. The environment defines the power.
15. Promises vs Callbacks (The Confusion Point)
Callback Style
fs.readFile("file.txt", () => {
console.log("file");
});
- I/O completes
- Callback queued in Poll
- Not a microtask
Promise Style
await fs.promises.readFile("file.txt");
console.log("after");
Split into two parts:
- File read (Poll phase)
- Promise resolves → schedules microtask
So:
- File I/O → Poll callback
- Code after await → microtask
🔥 Final Truths to Remember
- Event loop schedules callbacks.
- V8 executes JavaScript.
- Microtasks run between callbacks.
- Poll is the heart of Node.
awaitnever blocks.- Single-threaded ≠ slow.
- Blocking JS blocks everything.
If you understand this model deeply, you no longer “guess” Node.js behavior — you can predict it.
That’s the real goal.