Event Loop: Advanced Internals
The Node.js event loop is the mechanism that allows a single-threaded process to handle thousands of concurrent I/O operations without blocking. You've used it every time you wrote async/await or setTimeout. This page goes deeper: the exact phases the loop cycles through, the difference between microtasks (Promises, queueMicrotask) and macrotasks (timers, I/O callbacks), what process.nextTick actually does, and why blocking the loop — even briefly — causes latency spikes across every concurrent request.
The six phases of the event loop
Text
Each iteration ("tick") of the event loop goes through these phases in order:
┌──────────────────────────────────────────────────────┐
│ │
│ 1. timers setTimeout / setInterval callbacks │
│ 2. pending I/O I/O callbacks deferred to next tick│
│ 3. idle/prepare (internal libuv use only) │
│ 4. poll wait for new I/O events │
│ 5. check setImmediate() callbacks │
│ 6. close events socket.on('close', ...) etc. │
│ │
└──────────────────────────────────────────────────────┘
Between EVERY phase transition:
→ drain all process.nextTick() callbacks
→ drain all Promise microtasks (resolved .then / async continuations)Microtasks (nextTick + Promises) run between every phase transition — they always complete before the loop moves to the next phase
The loop's phases are macrotask queues. **Microtasks** are a different queue that gets drained **completely** between every phase transition — they don't wait for the next iteration. This means: if a Promise `.then` callback schedules another `.then`, and that schedules another, all of them run to completion before the event loop advances to the next phase. `process.nextTick` is even higher priority than Promise microtasks — nextTick callbacks drain first, then Promise microtasks, then the next phase begins. This ordering is rarely observable in normal code, but matters when debugging subtle timing bugs.
Phase ordering in code
TS
console.log('1: synchronous')
setTimeout(() => console.log('5: setTimeout'), 0)
setImmediate(() => console.log('6: setImmediate'))
Promise.resolve().then(() => console.log('3: Promise microtask'))
process.nextTick(() => console.log('2: nextTick'))
queueMicrotask(() => console.log('4: queueMicrotask'))1: synchronous 2: nextTick 3: Promise microtask 4: queueMicrotask 5: setTimeout 6: setImmediate
Order: synchronous → nextTick → Promise microtasks → queueMicrotask → timers phase → check phase (setImmediate)
`process.nextTick` callbacks run before Promise microtasks, which run before `queueMicrotask`, which all run before macrotask callbacks like `setTimeout`. `setImmediate` runs in the **check phase**, after the poll phase, so in I/O callbacks it reliably fires before `setTimeout(fn, 0)` — but outside I/O callbacks the ordering between them is not guaranteed (it depends on how fast the OS clock ticks vs. how fast libuv processes the check queue). When you need something deferred but as soon as possible, `setImmediate` is the right tool inside I/O callbacks; `queueMicrotask` or `Promise.resolve().then()` for the highest-priority deferral outside I/O.
The poll phase — where Node spends most of its time
Text
Poll phase behaviour:
1. If there are pending timers (setTimeout/setInterval) that have expired → exit poll, run timers phase
2. If there are I/O callbacks queued → execute them (up to system limits)
3. Otherwise → BLOCK here, waiting for:
• new I/O events from the OS (file reads, network data, etc.)
• a timer to expire (sets the block timeout)
• a setImmediate to be scheduled (unblocks immediately)
This is where Node waits for work. A healthy event loop spends most time here.Blocking the event loop
TS
// ❌ BLOCKS the event loop — no other request can be served during this:
app.get('/report', (req, res) => {
const result = computeHeavyReport() // 200ms of pure CPU work
res.json(result)
// Every other in-flight request is frozen for 200ms
})
// ✅ CPU work offloaded to a worker thread — event loop stays free:
import { Worker } from 'node:worker_threads'
app.get('/report', (req, res) => {
const worker = new Worker('./report-worker.js')
worker.on('message', (result) => res.json(result))
worker.on('error', (err) => res.status(500).json({ error: err.message }))
worker.postMessage({ params: req.query })
})Any synchronous operation longer than ~1ms in a request handler blocks ALL concurrent requests — CPU work belongs in worker threads
Node's single-threaded event loop means CPU time is a shared resource. A route handler that runs synchronous computation for 200ms doesn't just take 200ms to respond — it **prevents every other in-flight request from making progress** for those 200ms. At 100 concurrent requests, they all wait. Common event-loop blockers: JSON.parse/stringify on megabyte payloads, synchronous crypto (use the async versions), recursive tree algorithms, regex with catastrophic backtracking, reading large files with `fs.readFileSync`, and tight loops over large arrays. The fix is either to use async I/O (Node handles it without blocking), offload to [worker threads](/nodejs/worker-threads) for CPU work, or break large loops into chunks using `setImmediate` to yield between chunks.
Measuring event loop lag
TS
// Measure event loop lag: schedule a 1ms timer and see how late it fires
function measureELLag(): number {
const start = Date.now()
return new Promise<number>(resolve => {
setTimeout(() => resolve(Date.now() - start - 1), 1)
})
}
// In production, use perf_hooks for precise measurement:
import { monitorEventLoopDelay } from 'node:perf_hooks'
const histogram = monitorEventLoopDelay({ resolution: 10 })
histogram.enable()
setInterval(() => {
console.log({
p50: histogram.percentile(50) / 1e6, // nanoseconds → milliseconds
p99: histogram.percentile(99) / 1e6,
max: histogram.max / 1e6,
})
histogram.reset()
}, 5000)nextTick recursion — a hidden starvation risk
TS
// ❌ Infinite nextTick recursion — I/O callbacks NEVER run:
function recursiveNextTick() {
process.nextTick(recursiveNextTick)
}
recursiveNextTick()
// The nextTick queue is drained completely before I/O runs.
// This loop never lets the event loop move past the microtask draining step.
// ✅ Use setImmediate for recursive async work — it yields to I/O between calls:
function recursiveImmediate() {
setImmediate(recursiveImmediate)
}
// setImmediate fires in the check phase, after poll — other I/O runs between calls.Never recurse with process.nextTick — use setImmediate to yield to I/O; nextTick starvation can freeze all pending I/O callbacks
`process.nextTick` drains its queue **completely** before I/O. If a nextTick callback schedules another nextTick, that runs next — and if it schedules another, and so on — the event loop never advances past the microtask phase. No network data is received, no file reads complete, no timers fire. This is a real bug: Node 0.x had this pitfall and it was a common source of mysterious "server stopped responding" incidents. `setImmediate` doesn't have this problem — it runs in the check phase, and between calls the poll phase can process I/O. Use `setImmediate` for any async recursion or "yield and continue" patterns.
libuv and the thread pool
Text
Node process
├─ V8 (JavaScript engine — runs your code, one thread)
└─ libuv (async I/O library)
├─ Event loop (single thread — coordinates phases)
├─ I/O polling (epoll/kqueue/IOCP — OS-level async, no threads needed)
└─ Thread pool (UV_THREADPOOL_SIZE, default: 4)
Used for: fs operations, DNS lookups (getaddrinfo), crypto (some ops), zlib
UV_THREADPOOL_SIZE=8 node server.js ← increase if you have many concurrent fs/crypto opsNetwork I/O is non-blocking at the OS level and doesn't use the thread pool — file I/O, DNS, and some crypto DO use libuv's thread pool
There's a common misconception that all of Node's async I/O uses threads. **Network I/O** (TCP, UDP, HTTP) uses OS-level async APIs (epoll on Linux, kqueue on macOS, IOCP on Windows) — these are truly non-blocking and don't need threads; a single thread can watch thousands of sockets. **File I/O**, DNS resolution (`dns.lookup`), and some crypto operations go through libuv's **thread pool** (default 4 threads). This means if you have more than 4 concurrent `fs.readFile` operations, the 5th will queue behind the 4th. Increase `UV_THREADPOOL_SIZE` if profiling shows thread pool starvation. For CPU-heavy work, use [worker threads](/nodejs/worker-threads) — they're separate V8 isolates with their own event loops, not libuv thread pool workers.
Next
How Node allocates and tracks memory at the V8 heap level: [Memory Management](/nodejs/memory-management).