The Node.js event loop: callback order, late timers and what blocks the main thread Intermediate
Estimated reading time: 8 min read
The problem
Section titled “The problem”A Node.js API calls the database asynchronously, so the team believes the server can serve many requests at once.
Then a new handler runs JSON.parse on a large payload that takes 300 ms. Every other request’s latency rises, even the route that only returns pong; a setTimeout(fn, 0) also runs hundreds of ms late.
The cause: JavaScript callbacks run on one main thread. The event loop can only pick the next callback once the current callback returns control. This article gives you a model accurate enough to read logs, predict execution order correctly and find bottlenecks.
Rules to remember
Section titled “Rules to remember”These rules are deterministic. Anything else, the article states explicitly as not guaranteed.
| # | Rule | Verified by |
|---|---|---|
| 1 | After each callback, Node drains the whole nextTick queue, then the whole microtask (Promise) queue, repeating until both are empty |
Demo below, Node 24.17 |
| 2 | Inside an I/O callback, setImmediate always runs before setTimeout(0) |
Node docs + demo |
| 3 | setTimeout(fn, 0) is effectively setTimeout(fn, 1) |
timers docs |
| 4 | At top level, the order of setTimeout(0) and setImmediate is not deterministic |
Node docs |
| 5 | The libuv threadpool defaults to 4 threads, adjustable with UV_THREADPOOL_SIZE (max 1024) |
libuv docs |
| 6 | async does not make CPU code run in the background; synchronous computation always occupies the main thread |
Mechanism |
The only exception to rule 1: in an ES module (.mjs), the main module’s top-level code runs inside a microtask, so Promise .then runs before nextTick. CommonJS is the other way around. Measured on Node 24.17: a .mjs file prints promise then nextTick; a .js file prints nextTick then promise.
The six libuv phases
Section titled “The six libuv phases”Loading diagram…
The six phases are not six threads or six queues running in parallel. The event loop walks through the phases in turn and runs the callbacks ready in each phase.
The nextTick queue and the microtask queue are not phases: they are drained between callbacks, whichever phase is current (the round node in the diagram).
| Phase | Runs | Notes |
|---|---|---|
| timers | setTimeout/setInterval callbacks that are due |
The due time is a minimum threshold, not an exact appointment |
| pending | Some deferred system I/O callbacks | Rarely a concern |
| idle, prepare | libuv internals | Unrelated to application code |
| poll | Most I/O callbacks; waits for new events if there is no work | Does not wait long if a setImmediate is pending |
| check | setImmediate callbacks |
Runs right after poll |
| close | Some close events, for example socket.on('close') |
On ordering: since libuv 1.45.0 (Node 20), timers run after poll in each iteration instead of before; for compatibility, libuv still runs timers once before entering the loop.
This change only affects the order of setImmediate and timers in some edge cases, so do not write code that depends on it.
nextTick and microtasks
Section titled “nextTick and microtasks”The ordering-demo.ts demo registers nested callbacks inside a timer callback. Actual output on Node 24.17, CommonJS:
B1 nextTick 1B1c nextTick 2 (queued from inside nextTick)B2 promise 1B1b promise queued from inside nextTickB3 promise 2 (queued from inside promise)B4 nextTick queued from inside promiseHow to read it: the nextTick queue is drained completely (including the just-added B1c), then the microtask queue in FIFO order (B2, B1b, B3). B4 is a nextTick added from inside a microtask, so it has to wait for the microtask queue to empty before it runs.
The operational consequence: these two queues do not yield to I/O. A chain of nextTick or Promises that keeps rescheduling itself starves I/O and timers, even if each callback is tiny.
Timers and setImmediate
Section titled “Timers and setImmediate”A1 I/O callback: setImmediateA2 I/O callback: setTimeout 0Inside a readFile callback, setImmediate (the check phase) always comes before the next iteration’s timers. That is why setImmediate is the right choice when you need to “run after the current I/O is done”.
setTimeout(fn, 0) is changed to 1 ms (timers docs). Measuring 5 chained 0 ms timers, three runs gave 6.0, 11.0 and 4.9 ms: at least about 1 ms per round, plus scheduler jitter. Never treat 0 as “run immediately”.
Where heavy work runs
Section titled “Where heavy work runs”| Work | Runs on | Consequence |
|---|---|---|
JSON.parse, heavy regex, CPU loops |
Main thread | Blocks every other callback |
Async fs.*, dns.lookup, crypto.pbkdf2, zlib |
libuv threadpool (4 threads) | The 5th job onward has to queue |
| Network sockets (TCP, HTTP) | Kernel (non-blocking I/O) | Does not take a pool thread |
| CPU-bound work that needs parallelism | worker_threads or a separate service |
Costs data transfer overhead |
Measured with a temporary script (not in the repo) on a 10-core machine, Node 24.17: 8 concurrent async crypto.pbkdf2 calls had the first 4 jobs finish around 54 to 65 ms and the next 4 around 107 to 119 ms, exactly two “waves” for a 4-thread pool.
If timers are late while main-thread CPU is low, check whether the threadpool is exhausted.
Measuring event loop delay
Section titled “Measuring event loop delay”The delay-demo.ts demo: idle for 300 ms, block the main thread for 200 ms, then print the histogram of monitorEventLoopDelay({ resolution: 10 }). Two real runs:
| Metric | Run 1 | Run 2 |
|---|---|---|
| min | 9.2 ms | 10.0 ms |
| p50 | 11.0 ms | 11.0 ms |
| p99 | 13.4 ms | 12.3 ms |
| max | 204.9 ms | 206.0 ms |
eventLoopUtilization |
0.27 | 0.27 |
Reading the result: the histogram records the delay of a timer sampled every 10 ms, so the baseline is about 10 to 11 ms (that is the sampling period, not an error).
One 200 ms block pushes max up to about 205 ms while p50 barely moves. So alert on p99 or max, not on the average.
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });h.enable();setInterval(() => { const p99Ms = h.percentile(99) / 1e6; // the histogram is in nanoseconds h.reset(); // export p99Ms to your metrics}, 10_000).unref();High delay only tells you the main thread is occupied, not which function is responsible. The next step is a CPU profile and logging payload sizes.
Real-world example
Section titled “Real-world example”Production checklist
Section titled “Production checklist”- Export
monitorEventLoopDelayp99 andeventLoopUtilizationas metrics, and alert on p99. - Limit body size and synchronous processing time in handlers; do not
JSON.parseor validate unbounded payloads. - Large CPU work goes through
worker_threadsor a separate service, with a concurrency limit. - No infinite recursion of
process.nextTickor Promises; usesetImmediateto yield to I/O. - If you use many concurrent async
fs/crypto/zlibcalls, monitor the threadpool; increaseUV_THREADPOOL_SIZEonly after measuring that the pool is the bottleneck. - Do not write tests that rely on the order of top-level
setTimeout(0)andsetImmediate.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Why does infinitely recursive nextTick mean timers never run? (2) Inside an fs.readFile callback, in what order do setTimeout(0) and setImmediate run? (3) Timers are late but main-thread CPU is low: what should you check?
Run the demos (Node 24 required, no external services):
npx --prefix examples ts-node examples/src/node-event-loop/ordering-demo.tsnpx --prefix examples ts-node examples/src/node-event-loop/delay-demo.ts