Skip to content

libuv & the Event Loop, One Level Deeper

This lesson assumes the six phases from libuv & the Threadpool — it does not repeat that diagram. Here we go one level deeper: how timers are actually ordered, what “the poll phase blocks” costs in real milliseconds, and the precise queue-draining order between process.nextTick and microtasks.

Internally, uv_run keeps pending timers in a data structure ordered by absolute due time (a min-heap in libuv’s implementation), not in registration order. Two timers with the same delay still fire in the order they were scheduled, because they end up with the same due time and the heap preserves insertion order for ties — but a timer with a shorter delay always jumps ahead of one with a longer delay, regardless of which was scheduled first:

Node.js
Output (verified on Node 24)
C: scheduled 3rd, delay 0ms (floor is ~1ms) -- shorter delay wins
A: scheduled 1st, delay 10ms
B: scheduled 2nd, delay 10ms

setTimeout(fn, 0) does not mean “run immediately” — Node enforces a floor of roughly 1ms, and the callback still has to wait for the current synchronous code and the microtask/nextTick queues to drain, then for the timers phase to be reached.

Poll-phase blocking and how pending timers shorten it

Section titled “Poll-phase blocking and how pending timers shorten it”

The poll phase is the only phase that can genuinely block, waiting for I/O. It does not block forever: if there is an active timer, libuv computes how long until that timer is due and blocks the poll phase for at most that long, so a due timer is never delayed by an indefinite I/O wait. If there is no I/O ready and no pending timer at all, poll can block until the next I/O event with no timeout — which is also why a Node process with nothing left to do (no timers, no pending I/O, no open handles) exits on its own instead of hanging.

setImmediate vs process.nextTick, precisely

Section titled “setImmediate vs process.nextTick, precisely”

process.nextTick and microtasks (Promise.then, queueMicrotask) are not phases of the libuv loop at all — they are drained by Node itself, in full, immediately after the current operation finishes and before the loop is allowed to move to its next phase. setImmediate, by contrast, genuinely is a libuv phase (check), which only runs after poll. Inside any callback, the drain order is: the entire nextTick queue first, then the entire microtask queue, repeating until both are empty, before anything else (including setImmediate) runs:

Node.js
Output (verified on Node 24)
1: sync code in the timer callback
2: nextTick (drained first, in full)
3: queueMicrotask (drained after nextTick, in full)
4: promise .then (also the microtask queue)
5: setImmediate (a real loop phase -- runs after nextTick/microtasks)

The older Node internal, process._tickCallback, is gone in modern Node — this queue is now driven internally without that specific hook being exposed, but the observable behavior above (nextTick fully drains, then microtasks fully drain, after every callback) is unchanged and still exactly how to reason about ordering. One genuine wrinkle: at the very top level of an ESM module specifically (not inside any callback, and not in CommonJS), module evaluation itself is wrapped as an async job, which can make a top-level microtask appear to run before a top-level nextTick — inside any real callback (a timer, an I/O event, an immediate), the order above always holds.

Measuring loop lag with monitorEventLoopDelay

Section titled “Measuring loop lag with monitorEventLoopDelay”

perf_hooks.monitorEventLoopDelay() keeps a histogram of how long the loop actually takes between iterations, which turns “the loop is blocked” from a guess into a number:

Node.js
Output (verified on Node 24)
max delay landed in the expected ~100ms range: true
mean delay stayed well below the max (most iterations were fast): true

Anything synchronous on the main thread blocks everything else — every socket, every timer, every pending promise — for as long as it runs. Three shapes account for most real-world cases:

Synchronous fs in a hot path. readFileSync on a large file blocks for the entire read:

Node.js
Output (verified on Node 24)
sync read of 50MB blocked the loop for over 5ms: true

JSON.parse of a large payload. Parsing is synchronous, full stop — there is no async JSON.parse in Node:

Node.js
Output (verified on Node 24)
payload size, rounded to nearest 10 MB: 30
parsed item count: 1000000
JSON.parse of a ~30MB payload blocked the loop for over 20ms: true

Catastrophic regex backtracking. A pattern like /^(a+)+$/ against a crafted non-matching input takes exponential time, all of it synchronous:

Node.js
Output (verified on Node 24)
matched: false
took over 50ms from catastrophic backtracking on 25 characters: true

A user-controlled string reaching a vulnerable regex is a real denial-of-service vector (a “ReDoS”): 25 characters already costs tens of milliseconds; each additional character roughly doubles it.

async_hooks and AsyncLocalStorage have a real cost

Section titled “async_hooks and AsyncLocalStorage have a real cost”

AsyncLocalStorage (built on async_hooks) is the standard way to carry a request ID or trace context across await boundaries without threading a parameter through every function. It is not free — propagating context through every async continuation has measurable overhead compared to code with no context at all:

Node.js
Output (verified on Node 24)
AsyncLocalStorage overhead per call, bucketed: large (>4x)
context is correctly scoped per call: true

That overhead is per async continuation, not per request — for typical HTTP handlers (a handful of awaits, not hundreds of thousands of bare Promise.resolve() calls in a tight loop) it is rarely the bottleneck, but it is not zero, and “just wrap everything in AsyncLocalStorage” is a real trade-off, not a free abstraction.

The event loop is a single loop with a fixed, ordered set of stops (timers, pending callbacks, idle/prepare, poll, check, close). nextTick and microtasks are not stops on that route at all — they are a toll booth that runs to completion between every single stop and even between statements within a stop, before the loop is allowed to continue. Blocking code is a stalled car in the middle of the road: it does not matter which stop it happened at, nothing behind it can move until it clears, because there is only one road and one car allowed on it at a time.

A single-threaded loop makes reasoning about ordering deterministic — there is no true race between two pieces of your own JS the way there would be with real threads and shared memory. The cost is that literally nothing else runs while your code is on the stack, so any synchronous work that is “too slow” for a request-serving process (a large JSON.parse, a vulnerable regex, a big sync fs call) has no cheap escape hatch inside the main thread — the real fixes are streaming instead of loading-then-parsing, a vetted non-backtracking regex library, async fs, or moving the work to a worker thread entirely (see Native Code & Workers).

  • “setImmediate always fires before setTimeout(fn, 0).” Only guaranteed inside an I/O callback (already past poll, so check is the very next phase). From the main module or a timer callback, the order between them is not guaranteed and can go either way run to run.
  • “process.nextTick and microtasks are the same queue.” They are two separate queues, drained in a fixed order (nextTick, then microtasks) after every operation — mixing them up leads to wrong predictions about exact interleaving, even though both drain before the next loop phase either way.
  • “Async code can’t block the event loop.” async/await and promises only control when a continuation runs, not whether the code inside it is synchronous. A JSON.parse or a regex .test() inside an async function blocks exactly as much as it would outside one.

Generated request handlers frequently include a synchronous “quick” operation that looks harmless in isolation and is not.

🐛 Spot the bug

// AI-written request handler
app.post('/import', (req, res) => {
const data = JSON.parse(req.body); // req.body can be arbitrarily large
const result = data.items.map(processItem);
res.json({ count: result.length });
});
Show the answer

JSON.parse is synchronous and has no size-based escape hatch — for a request body that happens to be tens of megabytes (verified above: roughly 20ms+ for a ~30MB payload, and it scales with size), this one line blocks the entire event loop, delaying every other in-flight request on the process for the same amount of time. Combined with no body-size limit upstream (a separate, related bug), a single large or malicious upload can stall the whole server. The fix is a body-size limit before parsing, and for genuinely large payloads, a streaming JSON parser instead of JSON.parse on the fully-buffered body.

Two timers are both scheduled with a 10ms delay, one right after the other. In what order do they fire?
Which runs first?
setTimeout(() => {
  process.nextTick(() => console.log("fn1"));
  queueMicrotask(() => console.log("fn2"));
}, 0);
What does perf_hooks.monitorEventLoopDelay() measure?
Why does this block the event loop, even though it is inside an async function?
async function handleImport(req) {
  const data = JSON.parse(req.body); // req.body can be tens of MB
  return data.items.length;
}
An AI-written handler does JSON.parse(req.body) with no upstream body-size limit. What is the runtime-level risk, precisely?