Key Takeaways
- The Node.js event loop schedules JavaScript callbacks; operating-system facilities and libuv's thread pool handle different kinds of asynchronous I/O.
- JavaScript callbacks run to completion on their thread. A slow callback can delay timers, completed I/O, and other requests.
- Timers specify scheduling thresholds, not exact execution times. Promise microtasks and process.nextTick() callbacks are separate from event-loop phases.
- The node:worker_threads module provides explicit parallel JavaScript execution for CPU-heavy work; it isn't the internal pool used by ordinary asynchronous file reads.
The Node.js event loop lets one JavaScript thread handle many overlapping I/O operations by running callbacks when their results become available. It coordinates completion; it doesn't perform every operation itself or make CPU-heavy JavaScript non-blocking. That distinction explains both Node.js's strength at I/O concurrency and its limits.
This guide uses Node.js 24 LTS as its baseline. As of October 17, 2026, Node.js 26 is the Current release line; use the official release table when choosing a supported production runtime.
Synchronous vs. Asynchronous File Reads
Start with ordinary synchronous execution:
console.log(1);
console.log(2);
console.log(3);1
2
3The same sequential execution becomes a problem when a statement waits for disk access. Save the following examples as separate .mjs files, which explicitly enable ECMAScript modules, and run them with node filename.mjs. Create a small MyFile.csv in your working directory first:
name,score
Ada,42A Synchronous Read Blocks the JavaScript Thread
// sync-read.mjs
import fs from 'node:fs';
function processData(data) {
console.log(`Read ${data.length} characters`);
}
function someOtherFunction() {
console.log('other work');
}
try {
const data = fs.readFileSync('MyFile.csv', 'utf8');
processData(data);
someOtherFunction();
} catch (error) {
console.error('Read failed:', error.message);
process.exitCode = 1;
}fs.readFileSync() waits for the read before returning. Neither processData() nor someOtherFunction() can execute during that wait. Synchronous I/O can be reasonable in a short script or during startup, but avoid it in a server's request-handling path.
An Asynchronous Read Lets Other Code Continue
// async-read.mjs
import fs from 'node:fs';
function processData(data) {
console.log(`Read ${data.length} characters`);
}
function someOtherFunction() {
console.log('other work');
}
fs.readFile('MyFile.csv', 'utf8', (error, data) => {
if (error) {
console.error('Read failed:', error.message);
process.exitCode = 1;
return;
}
processData(data);
});
someOtherFunction();Here, someOtherFunction() runs before the read callback. The callback receives an error or the file's contents later. These command-line examples report failures and set an unsuccessful exit status; a server should instead propagate the error to its request or job error handler.
A callback is just a function passed to another function; it doesn't inherently make an operation asynchronous. The API determines when it's called. Likewise, using a Promise or await changes how you express completion, not whether subsequent CPU work runs on the JavaScript thread. Review JavaScript Promises if that distinction is unfamiliar.
What the Node.js Event Loop Actually Does
The Runtime Connects JavaScript to I/O
V8 executes JavaScript. Node.js supplies host APIs such as filesystem access and networking, and integrates libuv, the C library behind its event loop. Browsers provide their own host APIs and scheduling model; this Node.js phase diagram isn't a browser event-loop specification.
Consider the original ordering example, now with its import and error handling:
// file-order.mjs
import fs from 'node:fs';
console.log('start');
fs.readFile('MyFile.csv', (error) => {
if (error) {
console.error('Read failed:', error.message);
process.exitCode = 1;
return;
}
console.log('file read');
});
console.log('end');For a successful read, the output is:
start
end
file readThe read's completion cannot interrupt the currently executing JavaScript. Node.js makes its callback eligible to run after the underlying work completes, then invokes it when event-loop processing reaches it.
Single-Threaded JavaScript Doesn't Mean a Single-Threaded Process
| Mechanism | What it handles | Where your callback runs |
|---|---|---|
| JavaScript event-loop thread | Application code, callbacks, and their synchronous computation | On that JavaScript thread |
| OS-backed network I/O | Non-blocking sockets and readiness/completion notifications | Back on the associated event-loop thread |
| libuv thread pool | Ordinary asynchronous filesystem operations and selected native operations, such as asynchronous crypto.pbkdf2() | Back on the associated event-loop thread |
| node:worker_threads | JavaScript you explicitly dispatch to another thread | The computation runs in the worker; messages are handled on the receiving thread |
Ordinary fs.readFile() calls use libuv's internal pool, not the worker_threads API. Network I/O generally uses OS facilities rather than dedicating a pool thread to each connection. Each JavaScript thread has its own call stack, and callbacks on that thread run to completion.
This makes overlapping I/O practical, but it doesn't guarantee that Node.js outperforms another runtime. Workload and implementation matter; see Why Node.js? for the broader trade-offs.
Node.js Event Loop Phases
This is a simplified view of a modern steady-state iteration, not every internal scheduling step:
+-----------------------------------------------+
| pending callbacks |
| -> idle / prepare (internal) |
| -> poll: I/O events and callbacks |
| -> check: setImmediate callbacks |
| -> close callbacks |
| -> timers: eligible timeout/interval callbacks|
+----------------------|------------------------+
+---> repeat
Separate from these phases:
JavaScript returns -> next-tick / microtask checkpoints
(ordering depends on context)The libuv design overview describes the underlying iteration. JavaScript callbacks can schedule additional work throughout it.
Pending Callbacks
This phase runs certain I/O callbacks deferred from an earlier iteration, including some TCP error notifications.
Poll
Poll retrieves I/O events and runs associated callbacks. When appropriate, it waits for I/O rather than continuously spinning; the wait depends on outstanding work and timer deadlines. Poll isn't simply “wait until every operation finishes.”
Check
Check runs callbacks scheduled with setImmediate(), after poll.
Close Callbacks
This phase runs certain handle-close callbacks. Not every event named close necessarily follows this scheduling path.
Timers
Timer processing runs eligible setTimeout() and setInterval() callbacks. A timer becoming eligible doesn't interrupt a running callback. These phase descriptions follow Node.js's event-loop guide.
Walking Through an I/O Completion
- Your JavaScript submits an asynchronous file read, then continues executing.
- libuv's pool performs the filesystem work. Completion is reported back to the loop thread.
- When Node.js invokes the read callback, that callback runs synchronously to completion.
- Next-tick and microtask processing can run before the loop continues to other callbacks.
- Check, close, and eligible timer processing follow as the iteration progresses.
The important boundary is between finishing I/O and executing its JavaScript continuation. A busy JavaScript thread can delay the latter even when the former is already done.
Timers and setImmediate(): Context Matters
setTimeout() and setInterval() are not precision clocks. Their delays establish scheduling thresholds; other callbacks and OS scheduling can postpone execution. Node.js normalizes a setTimeout() delay below 1 millisecond, including zero, to 1 millisecond. See the timer API documentation.
Don't depend on a universal ordering between a top-level setTimeout(fn, 0) and setImmediate(fn). However, when you schedule both inside an I/O callback like this, the immediate runs first:
// io-timers.mjs
import fs from 'node:fs';
fs.readFile(new URL(import.meta.url), (error) => {
if (error) {
console.error(error);
process.exitCode = 1;
return;
}
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});immediate
timeoutThe immediate is scheduled for check after the current poll processing. Use explicit completion dependencies—not competing timers—when correctness requires a particular order.
process.nextTick() and Promise Microtasks
Neither the next-tick queue nor the Promise microtask queue is an event-loop phase. Promise reactions and queueMicrotask() use the microtask queue; process.nextTick() uses Node.js's separate next-tick queue.
After an ordinary I/O callback returns, Node.js drains next-tick callbacks before Promise microtasks. Top-level ES-module evaluation is different because it's already processed through microtasks. Run this as microtasks.mjs:
// microtasks.mjs
console.log('start');
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
console.log('end');The first four lines are:
start
end
promise
nextTickThe timer and immediate logs follow; don't use their relative order here as a contract. In equivalent CommonJS top-level code, nextTick precedes promise. The process documentation explains this module-context distinction and recommends queueMicrotask() for most general-purpose userland deferral.
CPU-Heavy JavaScript Still Blocks the Event Loop
An asynchronous read doesn't make its processData() callback asynchronous computation. Large parsing jobs, expensive regular expressions, and long JavaScript loops can still monopolize the thread.
This deliberately inefficient Fibonacci calculation demonstrates the problem. Save it as blocking.mjs; the result prints before the timer can run:
// blocking.mjs
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
setTimeout(() => console.log('timer'), 0);
console.log(fib(40));Offload Computation with worker_threads
Save this standalone example as worker-example.mjs. The worker executes the calculation while the main thread remains available to handle its timer and other events:
// worker-example.mjs
import {
Worker, isMainThread, parentPort, workerData,
} from 'node:worker_threads';
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
if (isMainThread) {
setTimeout(() => console.log('main-thread timer'), 0);
try {
const result = await new Promise((resolve, reject) => {
const worker = new Worker(new URL(import.meta.url), {
workerData: 40,
});
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker exited with code ${code}`));
}
});
});
console.log('result:', result);
} catch (error) {
console.error('Computation failed:', error);
process.exitCode = 1;
}
} else {
parentPort.postMessage(fib(workerData));
}workerData supplies the input, and parentPort.postMessage() sends the result. This is explicit parallel JavaScript, separate from libuv's internal pool. For repeated jobs, use a bounded worker pool rather than creating a worker per request, because worker startup has overhead.
Choose a child process when you need a separate process or external executable. Consider a separate service or job system when computation needs independent scaling. Offloading protects responsiveness; it doesn't automatically reduce total computation time.
Production Checklist: Diagnose Before Tuning
- Measure loop delay.
monitorEventLoopDelay()fromnode:perf_hookssamples event-loop delay into a histogram. Its delay values are nanoseconds; divide by 1,000,000 to report milliseconds. Correlate them with request latency and CPU profiles. - Distinguish pool congestion from loop blocking. libuv's pool is shared across event loops in a process. Queued pool work can make filesystem operations slow even while JavaScript remains responsive. Don't treat
UV_THREADPOOL_SIZEas a fix for CPU-heavy JavaScript. - Bound work and memory. Limit request sizes and concurrent expensive jobs.
fs.readFile()buffers the whole file; consider streams for large inputs. - Keep callbacks short. Avoid synchronous I/O in request handlers and use bounded batching or workers for substantial computation.
Continue with Node.js performance optimization once you've identified whether the bottleneck is JavaScript, queued native work, or downstream I/O.
FAQ
What are the phases of the Node.js event loop?
The practical phases are pending callbacks, poll, check, close callbacks, and timers, with internal idle/prepare steps. In modern steady-state iterations, timer processing occurs after poll; next-tick and Promise microtask processing are not phases.
Is the Node.js event loop single-threaded?
A given event loop runs on one thread, and JavaScript callbacks on that thread execute one at a time. The Node.js process also uses other threads: ordinary asynchronous file reads use libuv's pool, while worker_threads lets you explicitly run JavaScript in parallel.
What is the difference between setImmediate() and setTimeout()?
setImmediate() schedules a callback for the check phase after poll, while setTimeout() schedules a callback after a delay threshold. Their top-level order isn't a reliable contract; when both are scheduled inside the same I/O callback, the immediate runs first.
How do process.nextTick() and Promise callbacks affect event-loop order?
They run at scheduling checkpoints before the event loop continues to other work. Next-tick callbacks precede Promise microtasks after ordinary I/O callbacks, but top-level ES-module evaluation changes that ordering; repeatedly replenishing either queue can starve I/O.
How can CPU-intensive work avoid blocking the Node.js event loop?
Move substantial JavaScript computation to a bounded worker_threads pool, or use a child process or separate service when appropriate. Smaller jobs can be divided into bounded chunks that yield with setImmediate(); wrapping a calculation in a Promise doesn't move it to another thread.
Further Reading
- Node.js event-loop guide — phases, timer changes, and callback ordering.
- libuv design overview — polling, loop iterations, and I/O mechanisms.
- Node.js filesystem APIs — read methods, buffering, and thread-pool usage.
- Node.js timers — scheduling behavior and delay normalization.
- Node.js process APIs — next-tick and microtask ordering.
- Node.js worker threads — parallel JavaScript and message passing.
- Node.js performance measurement — event-loop delay monitoring.
- Don't Block the Event Loop — workload bounds, blocking risks, and partitioning.
