Key Takeaways
- The Node.js event loop lets one JavaScript thread coordinate many pending I/O operations without assigning a JavaScript thread to each request.
- Network I/O generally uses operating-system facilities; particular filesystem, DNS, cryptography, and compression APIs use libuv's worker pool.
- A long-running JavaScript callback or synchronous operation delays other callbacks sharing that event loop.
- Promises and async/await change control flow, not where CPU-heavy JavaScript executes. Use bounded chunks or worker threads when appropriate.
These distinctions—not simply calling Node.js “single-threaded”—explain both its concurrency and its limits.
The Node.js event loop coordinates ready callbacks so a JavaScript thread can handle other work while asynchronous I/O is pending. It works well for I/O-heavy applications, but it cannot interrupt an expensive JavaScript function to serve another request on the same loop.
What “Single-Threaded” Means in Node.js
Separate the runtime into three responsibilities:
- V8 executes JavaScript. An isolate is an independent JavaScript execution environment with its own heap.
- Node.js supplies runtime APIs for files, networking, timers, and other system capabilities.
- libuv supplies the native event loop and cross-platform I/O infrastructure, including a worker pool.
JavaScript callbacks execute serially on a given isolate's thread. The process can still contain auxiliary threads, and you can create additional JavaScript execution threads with worker threads. Concurrency means multiple operations can be in progress; parallelism means work actually executes simultaneously.
JavaScript starts an asynchronous operation
|
Node.js API
|
+-------+--------+
| |
OS-backed I/O libuv worker pool
e.g. sockets e.g. file reads
| |
+-------+--------+
|
completion reported to event loop
|
JavaScript completion callback
|
promise reactions, when applicable
(processed as microtasks)The operating system or worker pool can make progress while JavaScript handles something else. Completion does not interrupt the current callback: JavaScript must return control before another callback can run on that thread.
For the engine side of this model, see The V8 JavaScript Engine.
Blocking Calls, Asynchronous I/O, and the Thread Pool
A slow operation isn't necessarily a blocking operation. “Blocking” means it prevents the thread from continuing; an asynchronous API returns control while its work is pending. Database queries and REST calls made through asynchronous clients are therefore not inherently blocking.
| Operation | Where the work happens | Effect on the JavaScript thread |
|---|---|---|
| Synchronous file read: fs.readFileSync() | The calling thread waits for the read. | Other JavaScript on that thread cannot run until the call returns. |
| Asynchronous socket I/O | Operating-system networking facilities, coordinated through libuv. | The thread can run other callbacks while I/O is pending. |
| Promise-based file read: fs.promises.readFile() | File operations use the underlying worker pool. | The thread can run other callbacks while the read is pending. |
| CPU-heavy JavaScript loop | The JavaScript thread executing the loop. | Other callbacks on that thread wait until the code yields or finishes. |
The mechanism depends on the API, not whether the operation sounds expensive. The filesystem documentation distinguishes synchronous, callback, and promise interfaces.
Compare Synchronous and Promise-Based File Reads
Save this as files.mjs and run node files.mjs. The examples use built-in modules; the .mjs extension explicitly selects ES modules.
import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import process from 'node:process';
const file = new URL(import.meta.url);
try {
// Comparison only: avoid synchronous reads in request handlers.
const syncText = readFileSync(file, 'utf8');
console.log('Synchronous read:', syncText.length, 'characters');
const pendingRead = readFile(file, 'utf8');
console.log('Async read started; JavaScript continues');
const asyncText = await pendingRead;
console.log('Asynchronous read:', asyncText.length, 'characters');
} catch (error) {
console.error('File read failed:', error);
process.exitCode = 1;
}The synchronous call holds the thread until it returns. The asynchronous call returns a promise; await suspends this module's continuation, not the entire JavaScript thread. It doesn't automatically make the processing you perform after the read nonblocking. See the JavaScript async/await examples for control-flow details.
Which Work Uses libuv's Pool?
Representative users include asynchronous file operations, dns.lookup(), asynchronous crypto.pbkdf2() and crypto.scrypt(), and asynchronous zlib operations. Ordinary socket traffic doesn't need a pool thread per connection, although establishing a connection can involve a pooled hostname lookup.
The pool is shared across event loops in the process. Long-running pool jobs can delay unrelated operations using that pool even when the main event loop remains responsive. Investigate contention before tuning UV_THREADPOOL_SIZE; increasing it does not move your JavaScript loops off the main thread.
How the Node.js Event Loop Phases Work
Ready callbacks aren't all handled through one interchangeable queue. The loop progresses through phases, each responsible for particular work. This is a simplified recurring cycle, not a diagram of every internal step.
pending callbacks
|
idle / prepare
|
poll <-- I/O readiness and completions
|
check <-- setImmediate()
|
close callbacks
|
timers <-- eligible setTimeout() / setInterval()
|
+------- back to pending callbacks
nextTick and promise microtasks are separate queues,
not additional phases in this cycle.- Pending callbacks: handles certain I/O callbacks deferred from an earlier iteration.
- Idle and prepare: internal housekeeping phases.
- Poll: processes I/O events and can wait for new activity when appropriate.
- Check: runs callbacks scheduled with setImmediate().
- Close callbacks: runs certain handle-close callbacks.
- Timers: runs eligible setTimeout() and setInterval() callbacks.
Waiting in poll for operating-system activity is normal. A long-running callback is different: it prevents other ready JavaScript from executing on that loop.
setTimeout() Versus setImmediate()
A timeout establishes an eligibility threshold, not an execution deadline. setImmediate() schedules a callback for the check phase after poll. At main-module startup, don't rely on a fixed order between setTimeout(callback, 0) and setImmediate(callback).
When both are scheduled inside the same I/O completion callback, the immediate runs before the newly scheduled timeout. Save this as ordering.mjs and run node ordering.mjs:
import { readFile } from 'node:fs';
import process from 'node:process';
readFile(new URL(import.meta.url), (error) => {
if (error) {
console.error('Read failed:', error);
process.exitCode = 1;
return;
}
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});For these two callbacks, the successful read produces immediate before timeout. Neither callback preempts the read-completion callback itself.
nextTick and Promise Microtasks Are Not Phases
A microtask is deferred JavaScript processed before returning to ordinary event-loop work. Promise reactions and queueMicrotask() use V8's microtask queue; process.nextTick() uses Node's separate next-tick queue. At ordinary callback boundaries, Node drains next-tick work before promise microtasks. Top-level ES-module evaluation already runs through the microtask queue, so its promise reactions can run before next-tick callbacks.
Node documents process.nextTick() as Legacy and recommends queueMicrotask() for most userland deferral. Neither is a good CPU-work scheduler: recursively replenishing these queues can starve I/O. Repeatedly awaiting Promise.resolve() doesn't provide the same event-loop yielding as awaiting a timer or immediate.
See How One Expensive Task Delays the Loop
Save this as blocking.mjs and run node blocking.mjs. It deliberately burns CPU to make the scheduling effect visible; it isn't a performance benchmark.
import { performance } from 'node:perf_hooks';
import { setImmediate as yieldToLoop } from 'node:timers/promises';
function burnCpu(milliseconds) {
const deadline = performance.now() + milliseconds;
while (performance.now() < deadline) {
// Deliberately occupy this JavaScript thread.
}
}
async function demonstrate(label, work) {
const started = performance.now();
const timerDone = new Promise((resolve) => {
setTimeout(() => {
const elapsed = Math.round(performance.now() - started);
console.log(`${label}: timer ran after ${elapsed} ms`);
resolve();
}, 10);
});
await work();
await timerDone;
}
await demonstrate('blocking', () => burnCpu(200));
await demonstrate('chunked', async () => {
for (let chunk = 0; chunk < 4; chunk += 1) {
burnCpu(50);
await yieldToLoop();
}
});The first timer cannot execute during the uninterrupted 200-millisecond loop. The chunked version gives the event loop opportunities to handle ready work between chunks. Its exact timer delay varies; splitting work improves responsiveness, not necessarily total execution time. The deliberately large chunks are for demonstration, not a latency target.
Here, yieldToLoop() is the promise-based setImmediate() from node:timers/promises. Merely marking the expensive function async would not achieve this: synchronous JavaScript inside an async function still occupies its executing thread.
Use Worker Threads for Substantial CPU Work
For sustained CPU-heavy JavaScript, use node:worker_threads to execute jobs on other JavaScript threads. These are different from libuv's internal pool: your application explicitly submits JavaScript work to them. Reuse a bounded pool rather than creating a worker for every request, because worker creation has overhead.
Keep the main loop responsible for coordinating I/O and returning results. Bound your job queue, account for data-transfer costs, and handle worker errors. Moving work elsewhere is not a substitute for limiting how much work you accept.
Performance, Scaling, and Fault Tolerance
Node.js doesn't require a JavaScript thread per request. Compared with a thread-per-request design, that can reduce application-thread overhead for some workloads. It doesn't guarantee lower memory use, greater throughput, or better latency: measure your workload and deployment rather than assuming the concurrency model wins.
You can use worker threads for CPU parallelism, child processes for separately running programs, or multiple server processes to distribute requests across cores. The official concurrency-model comparison explains these choices.
Responsiveness and fault tolerance are separate concerns. A blocked loop isn't necessarily a crashed process, and multiple threads do not provide process-level failure isolation. Worker threads remain within one process; separately supervised processes or service replicas provide a different failure boundary. Design error handling and restarts explicitly.
For broader runtime trade-offs, read Why Node.js?.
Detect Event-Loop Blocking in Production
monitorEventLoopDelay() samples event-loop delay into a histogram. Its resolution is configured in milliseconds, but its recorded delays are reported in nanoseconds. This startup fragment reports sampled p99 and maximum values in milliseconds:
// Fragment: add to an existing long-running server's startup code.
import { monitorEventLoopDelay } from 'node:perf_hooks';
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
const reporter = setInterval(() => {
if (delay.count === 0) return;
console.log({
eventLoopP99Ms: delay.percentile(99) / 1e6,
eventLoopMaxMs: delay.max / 1e6,
});
delay.reset();
}, 10_000);
reporter.unref();
// During shutdown:
// clearInterval(reporter);
// delay.disable();Interval-based samples include the sampling interval, so establish an idle baseline rather than expecting zero. The reporter's unref() prevents that reporting timer alone from keeping the process alive.
Correlate delay spikes with request latency and CPU profiles. As a diagnostic distinction, a slow asynchronous database operation can increase request latency without occupying the JavaScript thread; pool contention can also slow operations without a long JavaScript callback. See Node.js Performance Tips for the broader investigation.
Common Mistakes
- Wrapping CPU work in a promise: a promise does not automatically move computation to another thread.
- Using synchronous APIs in request handlers: other requests sharing the loop must wait for those calls to return.
- Ignoring input size: large JSON parsing tasks and pathological regular expressions can occupy the JavaScript thread. Bound untrusted inputs and test worst cases.
- Assuming more pool threads fix everything: libuv pool tuning addresses pool contention, not CPU-heavy JavaScript on the main thread.
Keep each unit of work small, bound concurrency, and measure under realistic load.
FAQ
Is Node.js single-threaded?
JavaScript callbacks execute serially within a given isolate's thread, but the Node.js process can contain other threads. Worker threads let you run JavaScript in parallel in additional execution environments.
Which operations use the libuv thread pool?
Representative users include asynchronous filesystem operations, dns.lookup(), asynchronous crypto.pbkdf2() and crypto.scrypt(), and asynchronous zlib operations. Ordinary socket I/O uses operating-system networking facilities rather than a pool thread per connection.
What is the difference between setImmediate() and setTimeout()?
setImmediate() schedules work in the check phase after poll; setTimeout() makes a callback eligible after a time threshold. Their startup ordering isn't something to rely on, but an immediate scheduled alongside a timeout inside an I/O callback runs first.
How do process.nextTick() and promise callbacks relate to event-loop phases?
They use separate queues rather than ordinary libuv phases. Next-tick work normally precedes promise microtasks at callback boundaries, but top-level ES-module evaluation has different ordering because it already runs through the microtask queue.
Is Node.js suitable for CPU-intensive tasks?
Yes, if you keep substantial CPU work off the request-serving event loop, typically with a bounded worker-thread pool or separate processes. Running that work directly in a request callback delays other callbacks sharing the loop.
Further Reading
- Node.js event-loop guide — phases and timer-ordering examples.
- libuv design overview — native I/O, polling, and loop stages.
- Node.js filesystem APIs — synchronous, callback, and promise behavior.
- Node.js process APIs — nextTick and microtask ordering.
- Node.js timers APIs — timeouts, immediates, and promise-based timers.
- Node.js worker threads — CPU parallelism and worker-pool considerations.
- Node.js performance measurement APIs — event-loop-delay histograms.
- Don't block the event loop or worker pool — blocking risks and mitigation strategies.
