awesome-everything RU
↑ Back to the climb

Browser & Frontend Runtime

Node.js event loop: phases, nextTick, and loop lag

Crux How Node''''s libuv-driven six-phase loop differs from the browser loop — process.nextTick priority, setImmediate vs setTimeout(0), blocking the Node loop, and measuring event-loop lag.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min

Your Node service handles 1000 req/s fine in staging. In production it occasionally spikes to 500 ms tail latency. DevTools is useless — Node has no frames, no INP. You need the equivalent diagnostic for a headless loop: event-loop lag.

Node’s libuv loop: six phases

Node’s libuv-driven loop runs through six phases per iteration:

PhaseWhat runs
timersExpired setTimeout / setInterval callbacks
pending callbacksDeferred I/O error callbacks
idle / prepareInternal libuv use
pollBlock on I/O (new socket data, file reads)
checksetImmediate callbacks
closeclose events (e.g., socket.destroy())

Between every phase, two queues drain: the process.nextTick queue (Node-specific, fires before microtasks) and the standard microtask queue (Promise.then, queueMicrotask).

Node loop queue priority
1st — synchronous code
runs to completion
2nd — process.nextTick
empties between every phase
3rd — microtasks (Promise.then)
empties between every phase
4th — timers phase
setTimeout / setInterval
5th — check phase
setImmediate
6th — close phase
close events

process.nextTick — highest-priority callback in Node

process.nextTick is the highest-priority callback short of synchronous code. It fires before microtasks — before Promise.then. Misuse causes the same starvation pattern as runaway microtasks in the browser, but at one priority level higher:

function tick() {
  process.nextTick(tick); // starvation at nextTick level
}
tick(); // I/O callbacks, setImmediate, timers never run

If you need “run after this synchronous block,” prefer queueMicrotask or Promise.resolve().then in Node — they have correct priority relative to I/O.

setImmediate vs setTimeout(0) in Node

Despite the names, setImmediate does not run immediately and setTimeout(0) does not run at zero delay.

  • setImmediate runs in the check phase (after I/O for this iteration).
  • setTimeout(0) runs in the timers phase of the next iteration.

Inside an I/O callback (file read, socket read), setImmediate always wins — it fires this iteration’s check phase before the next iteration’s timers phase. Outside I/O callbacks, the order is non-deterministic by spec. Code that relies on either ordering between the two is brittle; if you need a guaranteed ordering, use queueMicrotask (always before either) or stack them with explicit dependencies.

Quiz

Inside an I/O callback, which fires first — `setImmediate(fn)` or `setTimeout(fn, 0)` scheduled from that callback?

Quiz

A Node request handler does synchronous `JSON.parse` of a 3 MB body. What is the impact on other requests?

Why blocking the Node loop matters

Node has no concept of frames or input — but it is single-threaded just like the browser. A 500 ms JSON.parse on a request handler blocks every other request on the same process for 500 ms. There is no rendering safety net. Mitigation:

  • Offload CPU-bound work to a Worker thread (node:worker_threads).
  • Keep request handlers async-first.
  • Use streaming parsers (JSONStream, oboe.js) for large payloads.

The same “shorter tasks” discipline that fixes browser INP fixes Node tail latency.

Measuring event-loop lag in Node

The browser has INP and LoAF. Node has event-loop lag — the equivalent diagnostic for a headless loop. The idea: schedule a timer for T ms and measure how late it actually fired; the excess over T is time the loop was blocked by synchronous work.

perf_hooks.monitorEventLoopDelay() gives a histogram of this lag with percentiles. A healthy process holds p99 lag in single-digit ms; p99 in hundreds of ms means some handler regularly monopolises the thread, and every request that hits that window gets tail latency.

const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
// … later:
console.log(`p99 lag: ${h.percentile(99)}ms`);

This is the same measurement as INP in the browser — “how long did one task hold the only execution thread” — just expressed as lag instead of interaction delay.

Quiz

In Node, `process.nextTick(fn)` runs before `Promise.resolve().then(fn)`. Why does this ordering matter?

Why this works

Browser-vs-Node timing differences cause production bugs. A module tested only in Node may rely on process.nextTick firing before Promise.then. The same code in a browser (where nextTick does not exist) may use a polyfill that maps it to queueMicrotask — which has the same priority as Promise.then. Code that chains nextTick and Promise.then expecting a specific interleaving will produce different results in each environment. Test async ordering assumptions in both environments, not just one.

Recall before you leave
  1. 01
    List Node's six event-loop phases in order.
  2. 02
    Why is process.nextTick(fn) a footgun compared to queueMicrotask(fn)?
  3. 03
    A Node service's p99 event-loop lag climbs from 5 ms to 200 ms after a new release. What is the likely cause, and how do you find it?
Recap

Node’s event loop is driven by libuv through six ordered phases: timers, pending callbacks, idle/prepare, poll, check (setImmediate), and close. Between every phase, process.nextTick callbacks drain first (before microtasks), making it the highest-priority async primitive in Node and a starvation risk if used recursively. setImmediate fires in the check phase of the current iteration; setTimeout(0) fires in the timers phase of the next iteration — inside I/O callbacks, setImmediate always wins; outside, the order is non-deterministic. Because Node has no rendering step or input events, the equivalent of browser INP is event-loop lag, measurable with perf_hooks.monitorEventLoopDelay(). A p99 lag spike means a handler is holding the single main thread — blocking every concurrent request — and the fix is identical to the browser: shorter tasks, streaming parsers, or Worker threads for CPU-bound work.

Connected lessons
appears again in267
Continue the climb ↑React, Vue, and INP observability in production
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.