Skip to content
Knowledge Hub

The Node.js event loop: callback order, late timers and what blocks the main thread Intermediate

Estimated reading time: 8 min read

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.

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.

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.

The ordering-demo.ts demo registers nested callbacks inside a timer callback. Actual output on Node 24.17, CommonJS:

B1 nextTick 1
B1c nextTick 2 (queued from inside nextTick)
B2 promise 1
B1b promise queued from inside nextTick
B3 promise 2 (queued from inside promise)
B4 nextTick queued from inside promise

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

A1 I/O callback: setImmediate
A2 I/O callback: setTimeout 0

Inside 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”.

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.

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.

  1. Export monitorEventLoopDelay p99 and eventLoopUtilization as metrics, and alert on p99.
  2. Limit body size and synchronous processing time in handlers; do not JSON.parse or validate unbounded payloads.
  3. Large CPU work goes through worker_threads or a separate service, with a concurrency limit.
  4. No infinite recursion of process.nextTick or Promises; use setImmediate to yield to I/O.
  5. If you use many concurrent async fs/crypto/zlib calls, monitor the threadpool; increase UV_THREADPOOL_SIZE only after measuring that the pool is the bottleneck.
  6. Do not write tests that rely on the order of top-level setTimeout(0) and setImmediate.

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

Terminal window
npx --prefix examples ts-node examples/src/node-event-loop/ordering-demo.ts
npx --prefix examples ts-node examples/src/node-event-loop/delay-demo.ts