The Node.js Event Loop: How Async Really Works

The Node.js Event Loop: How Async Really Works
The event loop is the single most important concept in Node.js. Every piece of async code you write — every Promise, every setTimeout, every database query — flows through it. Developers who truly understand the event loop write better async code, debug race conditions faster, and build more performant servers.
This is also one of the most common Node.js interview topics. By the end of this module you will be able to predict the exact output of any async code sequence, explain the event loop phases to an interviewer, and understand why Node.js behaves the way it does.
This is Module 5 of the Node.js Full‑Stack Developer course.
The Three Components of Node.js Async
Before exploring the event loop, understand the three pieces that work together:
1. The Call Stack
The call stack is where your synchronous JavaScript code executes. It is a LIFO (Last In, First Out) stack. When you call a function, it is pushed onto the stack. When it returns, it is popped off.
function greet(name) {
return `Hello, ${name}`
}
function main() {
const result = greet("Alice")
console.log(result)
}
main()Execution order on the call stack:
main()pushedgreet("Alice")pushedgreetreturns → poppedconsole.log(result)pushed → executes → poppedmainreturns → popped- Stack is empty — event loop can run
The call stack can only execute one function at a time. This is what "single-threaded" means.
2. The Web APIs / libuv
When Node.js encounters an async operation, it hands it off — not to the call stack, but to libuv (for I/O, timers, DNS) or the OS kernel directly (for network I/O). These run outside the JavaScript thread:
// This does NOT block the call stack
fs.readFile('data.txt', (err, data) => {
console.log(data)
})
// Node.js hands the file read to libuv and moves on immediately3. The Event Queue (Callback Queue)
When an async operation completes, its callback is placed in the event queue. The event loop picks callbacks from this queue and pushes them onto the call stack — but only when the call stack is empty.
[Call Stack] ← event loop pulls from → [Event Queue] ← libuv pushes intoThe Event Loop Phases
The Node.js event loop is not a simple loop — it has six distinct phases, each with its own queue of callbacks to process. It cycles through them in order, repeatedly, until there is nothing left to do.
┌──────────────────────────────┐
┌─►│ 1. timers │ setTimeout, setInterval callbacks
│ └──────────────┬───────────────┘
│ ┌──────────────▼───────────────┐
│ │ 2. pending callbacks │ deferred I/O error callbacks
│ └──────────────┬───────────────┘
│ ┌──────────────▼───────────────┐
│ │ 3. idle, prepare │ internal use only
│ └──────────────┬───────────────┘
│ ┌──────────────▼───────────────┐
│ │ 4. poll │ retrieve new I/O events
│ └──────────────┬───────────────┘
│ ┌──────────────▼───────────────┐
│ │ 5. check │ setImmediate callbacks
│ └──────────────┬───────────────┘
│ ┌──────────────▼───────────────┐
└──│ 6. close callbacks │ socket.on('close', ...) etc.
└──────────────────────────────┘
↑ between each phase ↑
process.nextTick() and Promise callbacks drain herePhase 1: Timers
Executes callbacks scheduled by setTimeout() and setInterval() whose delay has expired.
setTimeout(() => console.log("timer 1"), 0)
setTimeout(() => console.log("timer 2"), 0)
// Both fire in the timers phaseThe delay value is a minimum delay, not a guarantee. setTimeout(fn, 0) means "run as soon as possible after 0ms" — but the event loop must complete its current phase first.
Phase 2: Pending Callbacks
Executes I/O callbacks deferred from the previous loop iteration — for example, TCP error notifications on some systems.
Phase 3: Idle, Prepare
Internal use only. You will never interact with this directly.
Phase 4: Poll
This is the heart of the event loop. Node.js:
- Calculates how long to block for new I/O events
- Processes all available I/O callbacks in the queue
- If
setImmediatecallbacks exist, moves to the check phase - If timers are ready, moves to the timers phase
When the poll queue is empty and there are no pending timers or setImmediate callbacks, Node.js will wait here for new I/O events rather than spinning the loop pointlessly — this is how a web server stays alive without wasting CPU.
Phase 5: Check
Executes setImmediate() callbacks. These always run after the poll phase completes, before the next timers phase.
setImmediate(() => console.log("setImmediate"))
setTimeout(() => console.log("setTimeout 0"), 0)
// Output when run from within an I/O callback:
// setImmediate ← always first inside I/O callbacks
// setTimeout 0Phase 6: Close Callbacks
Executes close event callbacks — for example, socket.on('close', ...) when a TCP connection is closed.
Microtasks: The Priority Queue
Between every event loop phase (and even between callbacks within a phase), Node.js drains two special queues:
process.nextTick()queue — highest priority- Promise microtask queue — resolved
.then()/async/awaitcontinuations
These are called microtasks and they always run before the event loop moves to its next phase. This means microtasks can delay the event loop if they keep adding more microtasks.
Priority Order (highest to lowest)
1. Synchronous code (call stack)
2. process.nextTick() callbacks
3. Promise microtasks (.then, async/await)
4. setImmediate() callbacks
5. setTimeout() / setInterval() callbacks
6. I/O callbacksSeeing It in Action
console.log("1 — sync start")
setTimeout(() => console.log("5 — setTimeout"), 0)
setImmediate(() => console.log("4 — setImmediate"))
Promise.resolve().then(() => console.log("3 — Promise"))
process.nextTick(() => console.log("2 — nextTick"))
console.log("1 — sync end")Output:
1 — sync start
1 — sync end
2 — nextTick
3 — Promise
4 — setImmediate
5 — setTimeoutWalk through it:
- Sync code runs first — two
console.logcalls process.nextTickfires before any other async- Promise
.thenfires next (microtask) setImmediatefires in the check phasesetTimeoutfires in the next timers phase
Burn this order into your memory. It comes up in every Node.js interview.
Async/Await and the Event Loop
async/await is syntactic sugar over Promises. Every await is a Promise microtask continuation:
async function fetchData() {
console.log("2 — inside async function (sync part)")
const result = await Promise.resolve("data")
// Everything after await is a microtask
console.log("4 — after await:", result)
}
console.log("1 — before async call")
fetchData()
console.log("3 — after async call (sync continues)")Output:
1 — before async call
2 — inside async function (sync part)
3 — after async call (sync continues)
4 — after await: dataThe code before the first await runs synchronously. Everything after await is scheduled as a microtask and runs after the current synchronous block completes.
The libuv Thread Pool
Certain Node.js operations are too complex or OS-specific to handle asynchronously at the kernel level. For these, libuv maintains a thread pool (default: 4 threads):
| Uses Thread Pool | Does NOT Use Thread Pool |
|---|---|
fs.* (file operations) | TCP/UDP networking |
DNS resolution (dns.lookup) | Pipes |
Crypto (crypto.pbkdf2, crypto.randomBytes) | Child processes |
zlib (compression) | Most timers |
const crypto = require('crypto')
// This runs on the libuv thread pool, not the main thread
crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', (err, key) => {
console.log('Hash computed:', key.toString('hex').slice(0, 20))
})
console.log('Main thread is not blocked')Output:
Main thread is not blocked
Hash computed: a3f2c8d1e9b4...The Thread Pool Limit
With the default pool size of 4, running more than 4 simultaneous CPU-heavy operations will queue them. You can increase it:
process.env.UV_THREADPOOL_SIZE = '8' // must be set before Node.js startsOr set it when starting the process:
UV_THREADPOOL_SIZE=8 node index.jsFor most web servers this is not a concern — the bottleneck is network I/O, which bypasses the thread pool entirely.
Common Event Loop Pitfalls
Pitfall 1: Blocking the Event Loop
Never run CPU-intensive synchronous work on the main thread:
// ❌ BLOCKS the event loop — no other requests can be processed
app.get('/bad', (req, res) => {
const result = heavyComputation() // takes 5 seconds
res.json({ result })
})
// ✅ Move CPU work off the main thread
app.get('/good', async (req, res) => {
const { Worker } = require('worker_threads')
// ... run in a Worker thread
})Pitfall 2: Infinite Microtask Recursion
Microtasks that keep scheduling more microtasks can starve the event loop:
// ❌ This will never let I/O callbacks run
function loop() {
process.nextTick(loop) // keeps re-queuing itself
}
loop()Use setImmediate() instead when you need to yield to I/O:
// ✅ Yields to I/O callbacks between iterations
function loop() {
setImmediate(loop)
}
loop()Pitfall 3: Misunderstanding setTimeout(fn, 0)
setTimeout(fn, 0) does not mean "run immediately after this line." It means "run in the next timers phase, after all microtasks, after setImmediate if inside I/O":
// Inside an I/O callback:
fs.readFile('file.txt', () => {
setTimeout(() => console.log("setTimeout"), 0)
setImmediate(() => console.log("setImmediate"))
// Output: setImmediate → setTimeout
// setImmediate always wins inside I/O callbacks
})A Complete Mental Model
Here is the full picture to hold in your head:
Your code runs → call stack empties
↓
Drain process.nextTick queue
↓
Drain Promise microtask queue
↓
Move to next event loop phase
↓
Execute callbacks for that phase
↓
Drain process.nextTick queue again
↓
Drain Promise microtask queue again
↓
Move to next phase...
↓
[Repeat until all queues empty → process exits]When you look at any async Node.js code, mentally label each callback:
- nextTick — runs before everything else
- Promise — runs before I/O and timers
- setImmediate — runs in check phase
- setTimeout/setInterval — runs in timers phase
- I/O callback — runs in poll phase
Node.js Full‑Stack Course — Module 5 of 32
You now have a complete understanding of the Node.js event loop. Continue to Module 6 to master callbacks, Promises, and async/await — the practical async patterns you will use in every project.
Summary
The event loop is the engine behind everything async in Node.js. Here is what to remember:
- Call stack executes sync code one frame at a time
- libuv handles I/O and CPU work on background threads
- Event queue holds callbacks waiting for the call stack to empty
- Six phases: timers → pending callbacks → idle/prepare → poll → check → close
- Microtasks (
process.nextTick, Promises) drain between every phase — before macrotasks - Priority order: sync → nextTick → Promise → setImmediate → setTimeout → I/O
- Never block the main thread with synchronous CPU work
setTimeout(fn, 0)is not instant — it runs in the timers phase after microtasks
Continue to Module 6: Callbacks, Promises, and Async/Await in Node.js →
Content powered by SearchFit.ai — for automated content at scale, visit https://searchfit.ai
