Node.jsBackendFull-Stack

The Node.js Event Loop: How Async Really Works

TT
TopicTrick Team
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.

javascript
function greet(name) {
    return `Hello, ${name}`
}

function main() {
    const result = greet("Alice")
    console.log(result)
}

main()

Execution order on the call stack:

  1. main() pushed
  2. greet("Alice") pushed
  3. greet returns → popped
  4. console.log(result) pushed → executes → popped
  5. main returns → popped
  6. 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:

javascript
// 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 immediately

3. 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.

text
[Call Stack] ← event loop pulls from → [Event Queue] ← libuv pushes into

The 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.

text
   ┌──────────────────────────────┐
┌─►│         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 here

Phase 1: Timers

Executes callbacks scheduled by setTimeout() and setInterval() whose delay has expired.

javascript
setTimeout(() => console.log("timer 1"), 0)
setTimeout(() => console.log("timer 2"), 0)
// Both fire in the timers phase

The 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:

  1. Calculates how long to block for new I/O events
  2. Processes all available I/O callbacks in the queue
  3. If setImmediate callbacks exist, moves to the check phase
  4. 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.

javascript
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 0

Phase 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:

  1. process.nextTick() queue — highest priority
  2. Promise microtask queue — resolved .then() / async/await continuations

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)

text
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 callbacks

Seeing It in Action

javascript
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:

text
1 — sync start
1 — sync end
2 — nextTick
3 — Promise
4 — setImmediate
5 — setTimeout

Walk through it:

  1. Sync code runs first — two console.log calls
  2. process.nextTick fires before any other async
  3. Promise .then fires next (microtask)
  4. setImmediate fires in the check phase
  5. setTimeout fires 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:

javascript
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:

text
1 — before async call
2 — inside async function (sync part)
3 — after async call (sync continues)
4 — after await: data

The 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 PoolDoes 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
javascript
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:

text
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:

javascript
process.env.UV_THREADPOOL_SIZE = '8'  // must be set before Node.js starts

Or set it when starting the process:

bash
UV_THREADPOOL_SIZE=8 node index.js

For 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:

javascript
// ❌ 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:

javascript
// ❌ 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:

javascript
// ✅ 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":

javascript
// 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:

text
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