Understanding the Node.js Event Loop: Microtasks, Macrotasks, and Async Execution
Many Node.js bugs become much easier to understand once you know where a callback actually gets queued.
Why does process.nextTick run before a resolved Promise? Why does setImmediate sometimes run before setTimeout? Why can a single synchronous function freeze an entire server?
The answer is the event loop.
This article explains how the Node.js event loop works, the phases it runs through, and how microtasks and macrotasks interact during each iteration.
What the Event Loop Actually Is
The event loop is a loop that runs continuously, checking whether there is work to do and executing it in a specific order.
Node.js uses libuv, a C library, to handle I/O operations asynchronously using the operating system's native capabilities. When an async operation completes, its callback is queued and the event loop picks it up during the appropriate phase.
The key point is that JavaScript itself runs on a single thread. The event loop does not provide parallel execution. It provides deferred execution, allowing long running operations to be handled outside the main thread while their results are processed later without blocking application code.
The Phases of the Event Loop
The event loop runs through a fixed set of phases in order. Each phase has a queue of callbacks waiting to execute.
The main phases are:
-
timers: executes callbacks scheduled by
setTimeoutandsetInterval - pending callbacks: executes certain I/O callbacks deferred from the previous iteration
- idle, prepare: internal Node.js operations
- poll: retrieves new I/O events and executes their callbacks
-
check: executes
setImmediatecallbacks -
close callbacks: handles close events such as
socket.on('close')
The poll phase is where most I/O activity is processed. If no timers are ready and no setImmediate callbacks are waiting, the event loop can remain in the poll phase while waiting for new I/O events.
Once work becomes available, the loop continues through the remaining phases.
Microtasks vs Macrotasks
Not all asynchronous callbacks are treated equally. There are two categories: microtasks and macrotasks.
Macrotasks include setTimeout, setInterval, and setImmediate. Microtasks include resolved Promise callbacks and process.nextTick.
The critical difference is that microtasks are processed before the event loop continues to the next callback or phase. In Node.js, the process.nextTick queue is processed before the Promise microtask queue, which gives it even higher priority.
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => {
console.log('Promise');
});
process.nextTick(() => {
console.log('nextTick');
});
// Output:
// nextTick
// Promise
// setTimeout OR setImmediate
// setImmediate OR setTimeout
The exact ordering of setTimeout and setImmediate depends on where they are scheduled, but process.nextTick and Promise callbacks will always run first.
How setTimeout and setImmediate Differ
setImmediate is designed to execute during the check phase of the event loop.
setTimeout(fn, 0) schedules a timer that becomes eligible to run after a minimum threshold. That threshold is not a guarantee that the callback will execute immediately.
When called from the main module, the order of setTimeout(fn, 0) and setImmediate is not guaranteed because it depends on the state of the event loop when the process starts.
However, inside an I/O callback, setImmediate executes before timers.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
// Output:
// setImmediate
// setTimeout
This makes setImmediate a good choice when the goal is to run something immediately after the current I/O cycle completes.
Blocking the Event Loop
Because JavaScript runs on a single thread, any long running synchronous operation blocks the event loop completely.
While that code is executing, no timers, requests, or I/O callbacks can be processed.
function heavyComputation() {
let result = 0;
for (let i = 0; i < 1e9; i++) {
result += i;
}
return result;
}
heavyComputation();
Examples of operations that commonly block the event loop include:
- Image processing
- PDF generation
- Compression
- Encryption
- Large CSV imports
- Data aggregation
- AI inference workloads
The more CPU intensive the work becomes, the greater the impact on request latency and application responsiveness.
Where Worker Threads Fit
The event loop is excellent at coordinating asynchronous I/O, but it is not designed for CPU intensive workloads.
Worker Threads allow expensive computations to run in separate threads while the main thread remains available for handling requests and processing I/O events.
const { Worker } = require('worker_threads');
const worker = new Worker('./heavy-task.js');
worker.on('message', (result) => {
console.log('Task completed:', result);
});
Worker Threads introduce some overhead because data must be transferred between threads.
For genuinely CPU intensive tasks, that tradeoff is usually worth it because the event loop remains responsive while the computation runs elsewhere.
Practical Implications for Node.js Applications
Understanding the event loop changes how asynchronous code should be structured.
A few useful guidelines:
- Avoid CPU intensive work inside request handlers.
- Use
setImmediatewhen the goal is to yield after an I/O cycle. - Be careful with recursive
process.nextTickcalls. - Remember that Promise callbacks still run synchronously once scheduled.
A recursive process.nextTick can completely starve the event loop.
function recursiveNextTick() {
process.nextTick(recursiveNextTick);
}
// The event loop never gets a chance to continue
recursiveNextTick();
A safer approach is to yield using setImmediate.
function safeRecursive() {
setImmediate(safeRecursive);
}
safeRecursive();
This allows the event loop to continue processing timers, requests, and I/O callbacks between iterations.
The tradeoff with setImmediate in recursive patterns is slightly higher overhead per iteration compared to process.nextTick, but the benefit is that other callbacks get a chance to run.
Getting a Solid Mental Model
The event loop is not magic. It is a structured loop with well defined phases and predictable execution rules.
Most bugs involving asynchronous ordering, delayed callbacks, or unexpectedly blocked applications can be traced back to a misunderstanding of where work is being scheduled.
A useful next step is exploring Node.js profiling tools such as Chrome DevTools, the built in inspector, or Clinic.js. These tools make it much easier to visualize event loop activity and identify blocking operations before they become production issues.
What is the most surprising thing you have learned about the Node.js event loop?














