Event Loop in Node.js
The Node.js event loop is implemented by libuv and runs in phases:
timers → pending callbacks → idle/prepare → poll → check →
close callbacks, then again. Each phase has its own queue of callbacks.
After every callback, Node.js empties the process.nextTick queue and
then the microtask queue (promises, queueMicrotask), and only then runs
the next callback. The loop ends when there are no timers, pending I/O or other
active handles left.
Applies to: Node.js 24 LTS.
Details
The general model (one thread, call stack, tasks and microtasks) is the same as in the browser, see Event Loop. Node.js has no rendering step; instead, the "tasks" are split into phases.
Phases
| Phase | What runs there |
|---|---|
| Timers | setTimeout and setInterval callbacks whose delay has expired |
| Pending callbacks | Some system callbacks deferred from the previous iteration, for example a TCP ECONNREFUSED error on some Unix systems |
| Idle, prepare | Internal use only |
| Poll | Waits for new I/O events and runs almost all I/O callbacks (everything except timers, setImmediate and close callbacks). The loop blocks here when there is nothing else to do |
| Check | setImmediate callbacks |
| Close callbacks | close events, for example socket.on('close', …) |
Where Asynchronous Work Is Done
- Network I/O (TCP, UDP, HTTP): the operating system's non-blocking sockets (epoll, kqueue, IOCP). No extra threads.
- File system,
dns.lookup, somecryptoandzlibfunctions: the libuv thread pool (4 threads by default, set withUV_THREADPOOL_SIZE). When the work is done, its callback is queued for the poll phase. - Your JavaScript callbacks: always on the main thread, one at a time.
process.nextTick and Microtasks
| Queue | Filled by | When it runs |
|---|---|---|
process.nextTick queue | process.nextTick(fn) | Right after the current callback, before promises |
| Microtask queue | then/catch/finally, await, queueMicrotask | After the nextTick queue is empty |
- Both queues are drained completely, including callbacks added along the way,
so a recursive
process.nextTickor promise chain starves I/O: the loop never reaches the poll phase. - Since Node.js 11, the queues are drained after each
setTimeout/setImmediatecallback, not after the whole phase, so the order matches the browser. - In an ES module the top-level code itself runs as part of a promise job,
so promise callbacks queued there run before
process.nextTickones. In CommonJS,nextTickruns first. - The Node.js docs recommend
queueMicrotaskoverprocess.nextTickfor new code.
setImmediate vs setTimeout(fn, 0)
- Inside an I/O callback,
setImmediatealways runs first: after poll comes check, and the timer waits for the next iteration. - In the main module the order is not guaranteed: it depends on whether 1 ms
has passed by the time the loop starts (
setTimeout(fn, 0)is treated as 1 ms).
Timers: Differences From the Browser
setTimeoutreturns aTimeoutobject, not a number. It hasunref()(do not keep the process alive for this timer),ref()andrefresh().- Promise versions are in
node:timers/promises:await setTimeout(1000),for await (const _ of setInterval(1000)). setImmediateandprocess.nextTickare not available in browsers.- A long callback blocks the thread in both environments: in the browser it freezes the page, in Node.js it delays every other request.
Example
order.cjs
// Run with: node order.cjs
const fs = require('node:fs');
console.log('1: sync');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('3: promise'));
process.nextTick(() => console.log('2: nextTick'));
fs.readFile(__filename, () => {
setTimeout(() => console.log('5: timeout in I/O'), 0);
setImmediate(() => console.log('4: immediate in I/O'));
});
1,2,3: synchronous code, then thenextTickqueue, then microtasks.4before5: inside an I/O callback the check phase comes before the next timers phase.timeoutfrom the main module is printed somewhere after3; its exact place depends on timing.- If the file is renamed to
order.mjs(withimportinstead ofrequire),3: promiseis printed before2: nextTick.
Follow-up Questions
process.nextTickorsetImmediate? In effect the names are swapped:nextTickruns before the loop continues,setImmediatein the check phase.- How is this different from the browser event loop? The browser has tasks,
microtasks and rendering; Node.js splits tasks into libuv phases and adds the
nextTickqueue andsetImmediate. See Event Loop in the browser. - What blocks the event loop? Synchronous CPU work,
*Syncfile functions,JSON.parseof huge strings, catastrophic regular expressions. Move heavy work toworker_threads. - How do you detect a blocked loop?
perf_hooks.monitorEventLoopDelay(), or measure the delay between asetIntervalthat should fire every N ms and its actual calls. - When is
setImmediateuseful? To yield to I/O between chunks of long work, or to run code right after the current I/O callbacks.
Common Mistakes
- "Node.js gives all asynchronous work to the kernel." Network I/O, yes; file system and DNS lookup go to the libuv thread pool.
- "
process.nextTickruns on the next iteration of the loop." It runs right after the current callback, before promises and before any phase. - "
setImmediateruns immediately." It runs in the check phase of the current or next iteration. - "
setTimeout(fn, 0)always runs beforesetImmediate." Only the opposite is guaranteed, and only inside an I/O callback. - "Timers in Node.js do not block, unlike the browser." A callback blocks the thread in both; only the consequences differ.