Node.js Event Loop Explained: Phases, Microtasks, nextTick, and setImmediate
Node's event loop works thanks to two layers acting together ie. libuv's native loop engine and Node's own JavaScript scheduling rules. Different types of tasks - like timers, I/O callbacks, check steps, close handlers, promise reactions, and process.nextTick() callbacks - all wait in their own separate queues. Node then clears these queues out at specific checkpoints.
The way these tasks run follows a straightforward pattern - as long as JavaScript code is actively running on the call stack, any queued callbacks just have to wait their turn. The exact moment the stack empties, Node clears its high-priority internal queues and then lets libuv move forward through its loop phases. Timer callbacks run when timers expire, while most network and file completion callbacks come through paths related to data polling. Meanwhile, setImmediate() callbacks run during a specific check phase, and close handlers execute even later.
The exact order of execution depends on how the JavaScript call stack, process.nextTick(), V8 promise jobs, and the active libuv phases all interact. This combination is exactly why setTimeout(..., 0) and setImmediate() can sometimes swap places depending on where they are called, but maintain a rigid, predictable order in others.
We will look closer at how promises handle scheduling in Promise microtasks. For more on process startup, active resource tracking, and how an app shuts down, check out Node.js process lifecycle.

Figure 1 - Node runs JavaScript to a callback return point, drains process.nextTick() callbacks and V8 microtasks, and then continues through libuv phase work.
What the Event Loop Does
The event loop can be thought of as a coordinator, constantly shifting your application between executing JavaScript code and waiting for native system events. It kicks into gear right after your main script finishes running and the call stack clears out. From that point forward, the process stays alive as long as there are active resources - like open network sockets, running servers, or active timers - keeping it going.
The Call Stack
A JavaScript function frame stays on the call stack until that function finishes running or throws an error. While a piece of code is actively running, no queued callback can break in and execute on that thread. This holds true even if a callback is completely ready to run, simply because the main thread is still busy processing the current stack.
The stack works on a Last-In, First-Out basis. When one function calls another, the new function sits right on top of the caller. Once that inner function finishes and returns, its frame is popped off, and control drops back down to the function directly underneath it.
function third() {
console.log("Three");
}
function second() {
console.log("Two");
third();
console.log("Done with second");
}
second();This outputs:
Two
Three
Done with secondIn this example, second() remains on the stack the entire time third() runs. Only after third() completely finishes can second() move on to its final log. On the main thread, JavaScript execution always belongs to whatever function is sitting at the very top of this stack.
What "Blocking" Truly Means
When people talk about "blocking the event loop," they simply mean that a function is stuck on the main JavaScript call stack for so long that it holds up everything else waiting to run. As long as that current function is executing, no timers, network callbacks, promise reactions, or setImmediate() tasks can break in to do their jobs.
You can see this delay in action whenever you run a heavy, synchronous operation on the CPU:
const crypto = require("node:crypto");
const start = Date.now();
setTimeout(() => console.log("Timer was delayed."), 100);
while (Date.now() - start < 500) {
crypto.pbkdf2Sync("password", "salt", 1000, 64, "sha512");
}
console.log("Blocking work finished.");If you run this code, you will notice that the timer only logs its message after the heavy loop completely finishes. This shows that a timer's delay is just a minimum waiting time - it is not a guarantee that it will interrupt running code the exact millisecond it expires.
It is easiest to understand how this code executes if you look at these five sequential steps -
setTimeout()registers a timer with Node that becomes eligible to run after roughly 100ms.- The
whileloop monopolizes the call stack for a solid 500ms by continuously crunching synchronous crypto math. - The 100ms timer threshold passes while the loop is still actively running, but Node cannot pause the loop to handle the callback.
- The call stack finally empties once the 500ms loop runs its course and finishes execution.
- Node revisits its timer phase during the next event loop tick and can at last execute the delayed timer callback.
A single slow function can easily stall every other task in your entire application. While Node's architecture is brilliant at preventing I/O operations from blocking the main stack, it cannot stop poorly written, CPU-heavy JavaScript from freezing the thread on its own.
V8, Libuv, and Bindings
The Node runtime is a well-coordinated ecosystem built out of V8, Node's internal C++ bindings, libuv, and the standard JavaScript library. The event loop sits right at the intersection of these layers, and understanding its execution order requires looking at how these pieces hand off work to one another.
The V8 Execution Engine
This section briefly highlights the specific parts of V8 that directly influence how the event loop handles execution ordering. We already talked about these in the previous chapter.
V8,the Google's open-source engine serves as the heartbeat of JavaScript execution. Within Node, V8 is the component responsible for parsing and compiling your script, managing your application's active functions on the call stack, allocating memory on the heap, and running garbage collection to clean things up.
This architectural boundary exists because V8 doesn't actually know how to talk to your operating system's file system or handle network traffic on its own. Standard utilities like setTimeout(), fs.readFile(), and http.createServer() are host APIs provided by Node, not V8. To get real work done, the runtime must constantly translate JavaScript values inside V8 into native C++ code that the rest of the system can understand.
Libuv
Libuv handles the heavy lifting for non-blocking network I/O, timers, child processes, asynchronous file-system operations, and the worker pool used for specific blocking tasks.
All the core event loop mechanics - including handles, requests, polling integration, timers, and phase progression - belong to libuv. Node simply drives JavaScript callback execution on top of this engine, clearing out its own priority queues whenever the runtime transitions from native code back to JavaScript.
We also talked about how Libuv also acts as a bridge that hides the differences between various operating systems. While Linux relies on epoll, macOS uses kqueue, and Windows depends on I/O Completion Ports (IOCP), Node can expose a single, portable JavaScript API because libuv wraps these platform-specific mechanisms into a unified native abstraction.
Another important component is the libuv worker pool. Even though JavaScript runs on a single main thread by default, libuv manages a global pool of background threads to handle tasks where the operating system lacks a practical non-blocking interface. Node relies on this pool for file-system operations, dns.lookup(), and intensive crypto or compression work. The pool defaults to four threads, but you can adjust this at startup using the UV_THREADPOOL_SIZE environment variable up to a maximum of 1024.
Ever since libuv version 1.45.0 (which arrived in Node.js 20) timers execute only after the poll phase concludes, rather than checking both before and after. This changes how certain edge cases behave when mixing timers with setImmediate(), which is why you should always treat timer delays as loose thresholds rather than exact timing guarantees.
C++ Bindings
While V8 is busy executing JavaScript values and functions, libuv operates purely with native handles, requests, file descriptors, and low-level platform APIs. Node's C++ bindings is the actual glue connecting these two completely different environments.
For example, when you invoke fs.readFile("/path/to/file", callback), the execution flows through these seven distinct steps -
- Your JavaScript code calls the high-level
fs.readFile()function to start the request. - Node routes this call through its internal JavaScript wrapper and down into its C++ implementation layer.
- The native layer packages the file path, operational flags, and callback reference into a native request object.
- Libuv takes this packaged task and submits it directly to the worker pool for background execution.
- The background thread reads the file from disk and reports completion back to the main event loop the moment it finishes.
- Node translates the raw, native data buffer back into standard JavaScript values that V8 can understand.
- Node invokes your original JavaScript callback at the very next available callback return point.
This entire round trip - from JavaScript to C++, down to libuv, out to the operating system, and all the way back - is the way most asynchronous APIs in Node work. While network I/O typically interacts directly with the operating system's native readiness queues, file-system work relies heavily on this libuv worker pool to keep the main thread completely free.
Event Loop Phases
Many people think of the event loop as one massive queue where callbacks line up. No, that's not what it actually is. You might be better off thikning about it as a structured sequence of clear phases, each having its own specific queue and set of rules. Most developers learn these phases by their traditional names ie. timers, pending callbacks, idle/prepare, poll, check, and close callbacks.
Because the term "tick" can easily be confused with process.nextTick() - which handles tasks on the JavaScript side rather than the native side - we will use the word iteration to describe a single complete pass through this loop.
Overview of a Single Iteration
An iteration is not a fixed unit of time. A single pass can be incredibly fast or quite slow, depending entirely on how many callbacks are waiting to execute and how long the loop needs to pause while waiting for network or disk I/O.
Every time the loop moves into a new phase, it takes care of that phase's low-level native work first. After that, it executes the callbacks waiting in that specific phase's queue, usually in a first-in, first-out order. This execution continues until either the queue is completely empty or the engine hits a built-in system safety limit, at which point it advances to the next phase.
The Timers Phase
The timers phase handles the callbacks you schedule using setTimeout() and setInterval(). While traditional event loop diagrams are still great for understanding these different callback categories, there is an important update to keep in mind for the code you write from now. Starting with Node.js 20 and libuv 1.45.0, timers are processed strictly after the poll phase finishes. Older documentation and legacy Node versions often show timers running both before and after the poll phase, which is no longer the case.
Keep in mind that a timer callback is never guaranteed to execute at the exact millisecond you request. The delay you provide is actually a minimum threshold - the earliest possible moment the callback becomes eligible to run. When Node transitions to timer processing, it simply checks which timer thresholds have passed and runs the ones that are ready.
Under the hood, libuv organizes these active timers using a data structure called a min-heap, keeping the very next timer to expire right at the root. This smart layout allows the event loop to quickly calculate exactly how long it can afford to wait for other tasks before that next timer must run.
This min-heap design is an internal detail of libuv rather than an official JavaScript API guarantee. All you need to know is that checking for the next upcoming timer is incredibly efficient, though scheduling or canceling a massive number of timers all at once still carries a minor performance cost.
Pending Callbacks and Internal Operations
The loop also passes through a few internal phases that you will rarely ever interact with directly. First up is pending callbacks. This phase handles specific I/O callbacks that got pushed down the road from the previous loop iteration. A good example is a TCP ECONNREFUSED connection error on certain Unix systems.
Right after that come the idle and prepare phases. These are strictly used by libuv for internal bookkeeping right before the polling step, meaning they aren't exposed to you as public JavaScript scheduling APIs at all.
The Poll Phase
When we talk about polling, we basically mean asking the system "is anything ready yet?" over and over again. In this context, it means asking the operating system which I/O handles - like sockets or file descriptors - are ready to perform operations.
The poll phase is the real heavy hitter where the loop fetches new I/O events and executes most of your I/O-related callbacks. It has two main jobs.
First, the loop has to figure out exactly how long it can safely stall and block while waiting for I/O. To do this, it checks for any pending setImmediate() callbacks, active handles, or upcoming timer thresholds. Once it calculates that window, it calls the platform's native poll provider, like epoll_wait on Linux. It is worth noting that this blocking happens deep inside the operating system itself and is not a wasteful JavaScript busy loop eating up your CPU.
Second, once that wait ends - whether because I/O data finally arrived, a timer threshold expired, or the engine hit a safety limit - Node steps in to run the relevant callbacks. This is where a lot of your socket callbacks and file-system completions from the worker pool actually execute.
If the poll queue has items in it, the loop will continuously run those callbacks until the queue is completely drained or a system safety limit forces it to stop. But the moment the poll queue empties out, the loop changes its behavior based on two simple rules -
- If you have scheduled any callbacks using
setImmediate(), the loop completely cuts the poll phase short and jumps straight to the check phase. - If there are no
setImmediate()callbacks waiting, the loop will pause and wait for new I/O to arrive, but it will not wait past the very next due timer threshold or past libuv's internal system limits.
One final thing to keep in mind is that cleaning up and shutting down the process isn't handled by some specific callback at the end of the poll phase. The moment there are absolutely no referenced handles, active requests, timers, immediates, or workers left, Node simply realizes it has zero event-loop work left to do and exits cleanly.
The Check Phase
The check phase is dedicated entirely to running callbacks scheduled by setImmediate(). If you fire off a setImmediate() from right inside an I/O callback, it will run as soon as that poll callback returns and Node finishes clearing out any high-priority internal queues that the callback generated.
Deferring Follow-up Work After I/O
Remember that setImmediate() is strictly an in-process scheduling tool, not a durable background job system. If you have critical tasks that need to survive application crashes or handle automatic retries, you should use a persistent queue, a database-backed job table, or an external worker instead.
You should use setImmediate() when you want an I/O callback to finish up its primary tasks completely before some follow-up code executes later in the check phase -
const fs = require("node:fs");
fs.readFile(__filename, (error) => {
if (error) throw error;
console.log("I/O callback: primary work finished");
setImmediate(() => console.log("check phase: follow-up work"));
console.log("I/O callback: response can be sent now");
});Keep in mind that this follow-up work is not guaranteed to run immediately in actual wall-clock time, since other queued tasks might still be waiting in line. The real benefit here is the guarantee about execution order. When you schedule it from inside an I/O callback, a setImmediate() will always run during the check phase before a zero-delay timer (setTimeout(..., 0)) scheduled from that very same callback.
This makes it a great choice for yielding control during I/O-adjacent tasks within the same process, but it is definitely not a tool for heavy or durable background queues.
Close Callbacks
This phase is dedicated to handling certain "close" events. For eg. if you abruptly tear down a network connection using socket.destroy(), that socket's "close" event fires right here. But don't assume every close notification waits for this phase - some might get pushed through entirely different scheduling paths, like process.nextTick().
Once this phase wraps up, the event loop takes a step back to ask an important question - is there actually any reason to keep the process running? If you still have active resources - like referenced handles, pending requests, open sockets, or running servers - Node decides it isn't done yet and rolls right into the next iteration.
Microtasks, Phase Callbacks, and nextTick
Knowing the libuv phases tells you exactly where timers, I/O, immediates, and close callbacks originate. But those phases don't give you the whole picture on execution order. No, they don't. That is because Node also maintains high-priority queues that it completely clears out whenever a JavaScript callback finishes running.
Phase Callbacks and Microtasks
If you have spent time reading browser documentation, you have probably seen the word "macrotask" used for things like timers and I/O. Forget that term for a moment - in the Node world, it is much clearer to call them phase callbacks. These are simply the callbacks tied directly to libuv's phases, like timers, pending callbacks, poll, check, or close events.
Sitting right on top of these phase callbacks are two higher-priority queues. This is one of the most important concept you have to get right.
- The next tick queue is managed entirely by Node, and it is where callbacks end up whenever you call
process.nextTick(). - The V8 microtask queue is managed by V8 itself, housing promise reactions and anything you schedule with
queueMicrotask().
The exact moment Node hits a JavaScript callback return point, it drops everything to drain the next tick queue first, followed immediately by the V8 microtask queue, before it even thinks about moving on to the next event-loop phase. This is exactly why a promise reaction scheduled inside a timer callback can sneak in and run before the very next timer callback in that same phase.
Let's talk abou these two queues in a bit more detail.
The Highest Priority process.nextTick() Queue
Don't let the name fool you - process.nextTick() does not wait around for the next full event-loop iteration. Instead, it fires the exact millisecond the current operation on the JavaScript call stack finishes, right before Node takes its next step through the loop.
Node.js documentation actually flags process.nextTick() as Legacy. You should almost always default to queueMicrotask() when you just need to defer a task. The only times you really need nextTick anymore are when you are working with older callback API contracts or need to pass extra arguments directly to the deferred function.
Because Node insists on completely emptying the next tick queue before moving forward, writing recursive process.nextTick() calls will completely lock up your app, starving your timers and I/O. That massive starvation risk is a great reason to avoid using it as your default tool for scheduling. Here is a safe, bounded example to show how it behaves -
let count = 0;
const LIMIT = 5;
function repeatWithNextTick() {
console.log(`nextTick callback: ${++count}`);
if (count < LIMIT) process.nextTick(repeatWithNextTick);
}
setTimeout(() => console.log("Timer ran after nextTick emptied."), 0);
repeatWithNextTick();The output order is:
nextTick callback: 1
nextTick callback: 2
nextTick callback: 3
nextTick callback: 4
nextTick callback: 5
Timer ran after nextTick emptied.The first repeatWithNextTick() call runs synchronously and leaves a second call waiting inside process.nextTick(). The moment the main script wraps up, Node jumps straight into draining the next tick queue long before it ever touches the timer. Each next-tick callback schedules the next one in line until count finally hits the LIMIT. Only then does Node get the green light to move on and run the timer callback. If you took away that limit and let each callback schedule another nextTick forever, Node would get stuck in a loop draining that queue, and your timer would never get a turn.
The Promise Jobs Queue
Promises run on V8's microtask queue. Every time a promise resolves or rejects, any callbacks you attached through .then(), .catch(), or .finally() get lined up right there. Under the hood, async/await uses this exact same setup to handle what happens next after an awaited promise finishes.
When you are working with CommonJS top-level code or regular event-loop callbacks, the execution order is pretty rigid. You can think of it like this -
- Run the current JavaScript code or phase callback until it completely finishes.
- Drain the entire
nextTickqueue before doing anything else. - Drain the V8 microtask queue right after.
- Move on to the next callback or event-loop phase.
There is a pretty big catch here depending on your module format. ECMAScript module (ESM) top-level code is actually evaluated as part of V8's microtask processing. Because of that, promises and queueMicrotask() callbacks kicked off at the top level of an ESM file can actually jump the line and run before process.nextTick() callbacks. If you are in a CommonJS file, the opposite happens - process.nextTick() will always run before those promise jobs.
Here is a quick breakdown of how that order shakes out. Just remember that "microtasks" here refers to promise reactions and queueMicrotask() callbacks.
| Edge | Practical order |
|---|---|
| CommonJS top level | stack -> next tick -> microtasks -> event-loop work |
| ESM top level | module microtask -> microtasks -> next tick |
| Phase callback | callback -> next tick -> microtasks -> next phase or callback |
Keep in mind that when you await something, the code leading up to that await runs completely synchronously until the async function pauses and yields control. The entire remainder of that function only kicks back into gear as a promise reaction after the awaited value finally settles.
A Complex Execution Order Analysis
This CommonJS example deliberately leaves out top-level, zero-delay timers to make sure the output is completely predictable every single time:
const fs = require("node:fs");
console.log("1. Start");
Promise.resolve().then(() => console.log("4. Promise"));
process.nextTick(() => console.log("3. nextTick"));
fs.readFile(__filename, (error) => {
if (error) throw error;
console.log("5. I/O Callback");
setTimeout(() => console.log("9. Timeout from I/O"), 0);
setImmediate(() => console.log("8. Immediate from I/O"));
process.nextTick(() => console.log("6. nextTick from I/O"));
Promise.resolve().then(() => console.log("7. Promise from I/O"));
});
console.log("2. End");When you run this code, you will always get this exact output:
1. Start
2. End
3. nextTick
4. Promise
5. I/O Callback
6. nextTick from I/O
7. Promise from I/O
8. Immediate from I/O
9. Timeout from I/OThis specific order has nothing to do with the names of the APIs - it is entirely driven by where each task is scheduled.
First, "1. Start" and "2. End" print out synchronously because they are standard operations sitting right on the main call stack. The moment that main script finishes and the stack clears, Node immediately checks its high-priority queues, draining process.nextTick() first and the V8 promise jobs right after it.
A moment later, the file system finishes its work, and the fs.readFile() callback fires from an I/O path. Pay close attention to what happens inside this callback: before Node is allowed to move past this return point and continue through the loop, it pauses to clear out the brand-new next-tick and promise callbacks created right inside the handler. Only after those microtask queues are completely empty does Node advance. The immediate callback runs next during the check phase, while the zero-delay timer waits to execute until after that check phase finishes.
If you decide to add a top-level setTimeout(..., 0) right next to the fs.readFile() call, do not assume you will get a single, permanent output. On newer Node versions, that timer and the file callback can easily swap places depending on your system's startup speed, machine-specific timer thresholds, and exactly how fast the operating system finishes the I/O read.
This exact same execution model can quickly turn into a major performance bottleneck the moment a single callback holds onto control for a split-second too long.
Event Loop Performance Problems
All this smart scheduling only works if your callbacks actually return quickly. When a single callback runs for too long, it holds up everything else waiting behind it - it does not matter which phase or queue those waiting tasks originally came from.
Some blockers are completely obvious, like using synchronous APIs like fs.readFileSync() or running heavy CPU-bound loops. The trickier ones, though, are everyday JavaScript operations that look totally harmless until they have to deal with massive inputs.
Take large JSON operations, for example. Methods like JSON.parse() and JSON.stringify() are strictly synchronous. While their processing time scales linearly with the size of the input, a massive payload will still hijack the event loop long enough to tank your application's latency. If you are dealing with huge JSON streams, you are much better off enforcing strict size limits or switching over to a streaming parser. We'll talk about streaming in more depth in our streams chapter.
Complex regular expressions can be even worse because malicious input can be intentionally built to exploit them. A poorly constructed regex can trigger catastrophic backtracking, completely freezing the event loop. At that point, it ceases to be a simple performance hiccup and becomes a full-blown denial-of-service (DoS) risk. To stay safe, avoid nested quantifiers, overlapping alternatives, and backreferences in your validation paths unless you have explicitly tested how they handle the absolute worst-case scenario.
The Libuv Worker Pool Revisited
Remember that the libuv worker pool is entirely global and shared across your whole application. By default, it gives you just four threads to work with. While functions like fs.readFile() and crypto.pbkdf2() look and feel perfectly asynchronous on the JavaScript side, the actual native file-system or crypto heavy lifting might be stuck waiting in line inside that single, shared native queue.
This shared setup means completely unrelated tasks can end up bottlenecking each other. For eg. if your server uses fs.readFile() to grab a file from a slow network drive and simultaneously uses crypto.pbkdf2() to verify a user's password, both operations are fighting for a spot on that same small set of background threads. If five of those requests land at the exact same time, they will crowd the queue, and that hidden coupling will suddenly turn into a very visible spike in user latency.

Figure 2 — Worker-pool APIs can be asynchronous to JavaScript while still sharing one native queue. When all workers are busy, filesystem, DNS, crypto, or zlib work can delay one another.
- The first four requests each dispatch a task to the worker pool. If the file reads get there first, all four threads are now busy.
- The fifth request's
fs.readFile()call is made. libuv tries to hand it off, but the pool is full, so the task waits in a queue. - The password hashing for the first four requests also has to wait in that same queue until one of the file reads finishes and frees a thread.
This is exactly how a slow file-system task can end up wrecking your authentication latency. Everything running inside the libuv worker pool is interconnected. If your application is doing heavy file I/O, running dns.lookup(), processing crypto, or handling zlib compression, they are all competing for the exact same background threads.
When performance dips, your first instinct might be to just crank up UV_THREADPOOL_SIZE. No, don't do that blindly. You need to measure your actual pool pressure first. While throwing more threads at the problem can definitely boost throughput, it is not a free alternative - it also increases your memory footprint and introduces a lot of extra CPU scheduling overhead.
Profiling and Debugging the Event Loop
Knowing exactly how the event loop orders tasks is great, but it won't actually tell you if your loop is running smoothly. To know that, you have to measure it.
A quick way to get a local signal is with a timer-based latency check. This only makes sense in a long-running server application, not a quick one-shot script, because it relies entirely on tracking how late an interval callback arrives.
let lastCheck = Date.now();
setInterval(() => {
const now = Date.now();
const delay = now - lastCheck - 1000;
if (delay > 50) console.warn(`Event Loop Latency: ${delay}ms`);
lastCheck = now;
}, 1000);If you start seeing those warnings pop up, it means your interval callback got pushed back. Usually, that is a clear sign that some other long-running callback, a garbage collection pause, or a heavy synchronous operation is hogging the main thread and keeping the loop busy.
If you want something more robust for continuous production monitoring, you should skip the manual timer hacks and use Node's built-in histogram tool instead:
const { monitorEventLoopDelay } = require("node:perf_hooks");
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
setInterval(() => {
console.log("Event Loop Delay (ms):", h.mean / 1_000_000);
h.reset();
}, 5000);Keep in mind that this histogram tracks everything in nanoseconds, so you always need to divide by 1_000_000 to get a readable millisecond value. If you also need to figure out the exact ratio of how long the loop spends doing actual work versus just sitting idle, you can pair these delay metrics with performance.eventLoopUtilization().
For the really deep, low-level diagnostics, Node has async_hooks to map out the entire lifecycle of your asynchronous resources. But remember that it comes with a serious performance overhead and structural complexity, so you should generally leave it to APM vendors and highly targeted debugging sessions rather than running it constantly on a busy production server.
CPU-Bound Work and Worker Threads
Some tasks are just genuinely heavy on the CPU. You should keep in mind that asynchronous I/O doesn't magically make CPU work disappear - all it does is stop I/O waiting from hogging the JavaScript call stack.
Partitioning on the Event Loop
If you have a massive task that can be broken down into smaller pieces, you can use a technique called partitioning. The idea is simple, you do a small slice of the work, yield control back to the event loop using setImmediate(), and then pick up right where you left off on the next pass. While this keeps your application responsive to other incoming requests, but again, keep in mind that it does not actually make the total CPU computation finish any faster.
const bigArray = Array.from({ length: 1_000_000 }, (_, i) => i);
let sum = 0;
let index = 0;
function processChunk() {
const end = Math.min(index + 1000, bigArray.length);
for (; index < end; index++) sum += bigArray[index];
if (index < bigArray.length) setImmediate(processChunk);
else console.log("Processing complete. Sum:", sum);
}Notice how this approach tracks progress with a simple index pointer instead of constantly chopping up or shifting a massive array in memory. Each individual callback executes a strictly limited amount of work, steps aside, and gives other waiting callbacks a chance to take a turn. If you want the rest of your application code to keep moving forward before that very first chunk even kicks off, you just need to start the process with a clear scheduling point -
setImmediate(processChunk);
console.log("Started processing...");True Parallelism with worker_threads
We will cover worker_threads in deep detail in later chapters. For now, we are just looking at when standard event-loop scheduling isn't enough anymore.
When you need to tackle heavy, CPU-bound JavaScript work inside a single process, the node:worker_threads module is your go-to solution. Make no mistake, a worker thread is not the same thing as one of libuv's background worker-pool threads. Instead, it spins up an entirely separate thread complete with its own dedicated V8 isolate and its own independent event loop.
Because these workers run in complete isolation, they do not share standard JavaScript objects by reference. By default, any data you send back and forth is either cloned or completely transferred. While you can technically share raw memory using a SharedArrayBuffer, or pass ArrayBuffer instances directly, doing so opens the door to nasty race conditions and coordination bugs unless you manage them with Atomics. For almost every everyday scenario, plain message passing is the safer, smarter default.
We'll talk all about SharedArrayBuffer, ArrayBuffer and Atomics in an upcoming chapter - Views, Copies and Memory Ownership
// cpu-worker.js
const { parentPort, workerData } = require("node:worker_threads");
let result = 0;
for (let i = 0; i < workerData; i++) {
result += i;
}
parentPort.postMessage(result);The main thread starts that worker up and listens for its final result like this -
const { join } = require("node:path");
const { Worker } = require("node:worker_threads");
const worker = new Worker(join(__dirname, "cpu-worker.cjs"), {
workerData: 50_000_000,
});
worker.once("message", (result) => console.log("Worker result:", result));
worker.once("error", (error) => console.error("Worker failed:", error));Because that massive execution loop runs entirely inside the background worker, the main thread's event loop stays wide open and available to process incoming request callbacks. Just keep one important performance tip to keep in mind - if your application handles repeated CPU tasks, you should maintain a persistent worker pool instead of wasting valuable time spinning up a brand-new worker for every single job.
If you've worked with other languages like C, C++ or Rust, you probably know how to do that, if not, we'll go really deep into this territory in Volume 3 of NodeBook.
The cluster Module
Make sure you don't confuse worker_threads with the cluster module. I assume, just like me, you've once in your life thought of them being almost identical, or something that are built for the same use-case, but don't know when to use one over the other.
They look similar on the surface, but they solve entirely different problems. While cluster lets you run multiple Node processes that share the same server ports, it is built for scaling an entire server across multiple CPU cores and keeping them isolated. It is not meant for offloading a single heavy CPU calculation inside a random request handler.
Under the hood, cluster relies on child_process.fork(). You get a primary process that acts as a coordinator, handing out incoming network connections to various workers. Each worker runs as a completely independent process, meaning it gets its own event loop, its own V8 instance, its own dedicated memory space, and its own unique process ID.
Think of it this way use worker_threads when a single process needs to run JavaScript in parallel. Reach for cluster when you want completely separate process instances that can share server ports and stay safely isolated from one another.
You probably have already, indirectly used this if you've run your service using PM2 eg. pm2 start app.js. It's the same thing, but the clustering work is managed for you.
setTimeout vs setImmediate
Let's revisit these two in a bit more detail as there's this classic debate around which of these functions executes first is actually worth understanding because it perfectly tells us how Node handles scheduling under the hood.
setTimeout(..., 0) vs. setImmediate()
The short answer is it depends entirely on where you call them.
Case 1 - Calling them from the main script
setTimeout(() => console.log("Timeout"), 0);
setImmediate(() => console.log("Immediate"));If you run this snippet directly as a standalone script, the execution order is completely unpredictable. You might see Timeout print first, or Immediate might beat it to the punch. Remember that a timer's delay is just a minimum threshold, not an exact guarantee. Depending on how fast your machine boots up the process, the timer might cross its expiration threshold before the event loop even sets up its phases. Long story short, never write production logic that relies on either one running first here.
Case 2 - Calling them from inside an I/O callback
const fs = require("node:fs");
fs.readFile(__filename, (error) => {
if (error) throw error;
setTimeout(() => console.log("Timeout"), 0);
setImmediate(() => console.log("Immediate"));
});In this scenario, setImmediate() will always run first. Why? Because the outer file-reading callback is executing inside libuv's poll phase. When that callback finishes and schedules both the timer and the immediate task, the event loop naturally moves forward to the very next step - which is the check phase where setImmediate() lives. The timer callback, on the other hand, has to wait until the loop circles all the way back around to the timer processing phase.
Garbage Collection and Loop Latency
Don't forget that V8's garbage collector can throw a serious hit into your event-loop latency too. Even though a lot of GC cleanup happens incrementally or in the background, you will still run into stop-the-world pauses where all JavaScript execution grinds to a halt. Usually, these pauses are tiny and pass by completely unnoticed, but if your application is under heavy memory pressure, they start stacking up and showing up as nasty spikes in your tail latency.
While one of these pauses is happening, absolutely no JavaScript callbacks can run. To the outside world, your process looks completely frozen, exactly like it would if you ran a heavy, synchronous CPU task on the main thread. If you want to keep these pauses small and unnoticeable, you have to keep your object allocation rates under control and make sure you aren't holding onto massive chunks of memory in the heap unnecessarily.
Here is the best way to picture the whole system in your head. JavaScript code always runs until it completely finishes, Node steps in to drain process.nextTick() and promise microtasks the second a callback returns, and libuv quietly pushes native work forward phase by phase. Never treat zero-delay timers as a strict guarantee for which code runs first - they are just minimum thresholds. If you absolutely need things to happen in a specific order, you need to structure and control that execution flow yourself rather than guessing how the engine will schedule it.