How Node.js Works
Node.js runs JavaScript on the server by combining the V8 engine, libuv, and an event-driven I/O model - understanding this stack explains most production surprises.
Recipe
Quick-reference recipe card - copy-paste ready.
import { performance, eventLoopUtilization } from 'node:perf_hooks';
const start = eventLoopUtilization();
setImmediate(() => {
const elu = eventLoopUtilization(start);
console.log('Event loop utilization:', elu.utilization.toFixed(3));
});When to reach for this:
- Debugging latency spikes that do not show up in application logs
- Deciding whether to use worker threads vs child processes
- Explaining to teammates why a synchronous loop blocks all HTTP clients
- Sizing thread-pool defaults for crypto and compression workloads
Working Example
import { createServer } from 'node:http';
import { performance } from 'node:perf_hooks';
// Simulated CPU work - blocks the entire event loop
function hashSync(data: string, rounds: number): string {
let result = data;
for (let i = 0; i < rounds; i++) {
result = Buffer.from(result).toString('base64');
}
return result;
}
const server = createServer((req, res) => {
const t0 = performance.now();
if (req.url === '/fast') {
res.end('ok');
return;
}
if (req.url === '/slow') {
// 50M iterations block every other request in this process
hashSync('payload', 50_000_000);
res.end(`done in ${(performance.now() - t0).toFixed(0)}ms`);
return;
}
res.writeHead(404).end();
});
server.listen(3000, () => {
console.log('Try: curl localhost:3000/fast while /slow is running');
});What this demonstrates:
- A single Node process has one JavaScript execution thread
- CPU-bound loops prevent the server from accepting or finishing other requests
node:httpcallbacks run on the main thread - offload heavy work to workersperformance.now()helps measure event-loop stall duration in dev
Deep Dive
How It Works
- V8 compiles JavaScript to machine code and manages the call stack and garbage collector.
- libuv provides the event loop, async I/O (sockets, files, DNS), and a default 4-thread worker pool for operations that cannot be fully async at the OS level.
- JavaScript runs on one thread per process; concurrency comes from non-blocking I/O and scheduling callbacks when operations complete.
- Native addons and some
fs/cryptopaths use the libuv thread pool - saturation shows up as queueing, not CPU on the main thread.
Architecture at a Glance
| Layer | Responsibility | Blocking risk |
|---|---|---|
| V8 | Execute JS, GC | Sync JS loops |
| libuv event loop | Schedule timers, I/O callbacks | Long sync callbacks |
| libuv thread pool | Some fs ops, crypto.pbkdf2, zlib | Pool exhaustion |
| OS kernel | Actual socket/file I/O | Rare direct impact |
TypeScript Notes
import { Worker } from 'node:worker_threads';
// Offload CPU work so the main thread stays responsive
function runInWorker<T>(script: string, workerData: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const worker = new Worker(script, { workerData });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker exited ${code}`));
});
});
}Gotchas
- Assuming multi-core by default - one Node process uses one core for JavaScript unless you cluster or spawn workers. Fix: use
worker_threadsfor CPU tasks orclusterfor HTTP fan-out. - Synchronous file reads in request handlers -
fs.readFileSyncblocks the loop even thoughfs.promises.readFiledoes not. Fix: prefernode:fs/promisesor streams. - Thread pool starvation - many concurrent
pbkdf2or gzip operations queue behind the default 4 threads. Fix: setUV_THREADPOOL_SIZE(max 1024) or serialize heavy crypto. - Treating
setImmediateas "run ASAP" - it runs in the check phase after I/O polling, not before microtasks. Fix: read the event-loop ordering page before debugging timing bugs. - Large JSON.parse on big payloads - parsing is synchronous and blocks the loop. Fix: stream-parse or cap payload size at the reverse proxy.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
worker_threads | CPU-bound JS in the same process | You need full process isolation or shell commands |
child_process | Running CLI tools or separate runtimes | Shared memory or low-latency messaging is required |
cluster module | Multi-core HTTP on one machine | You deploy behind Kubernetes with pod-level scaling |
| External job queue | Long-running or retriable work | Sub-millisecond in-process dispatch is required |
FAQs
Is Node.js single-threaded?
JavaScript execution in a single Node process is single-threaded. libuv uses additional threads for I/O and its thread pool, but your JS callbacks run on one thread.
What does libuv actually do?
libuv wraps OS async primitives (epoll, kqueue, IOCP), drives the event loop phases, and runs a thread pool for blocking operations that have no async OS API.
Why does one slow endpoint slow down everything else?
All request handlers share the same JavaScript thread. A synchronous loop in one handler prevents others from running until it finishes.
How many threads does Node use by default?
One JS thread plus libuv's thread pool (default size 4, configurable via UV_THREADPOOL_SIZE).
Does async/await create new threads?
No. await yields control back to the event loop; when the awaited I/O completes, the continuation is scheduled as a microtask on the same thread.
Where does built-in fetch run?
fetch uses the same libuv-backed network stack as node:http. It is non-blocking from JavaScript's perspective but still competes for the same event loop.
What is the difference between V8 and Node?
V8 is the JavaScript engine (also used in Chrome). Node adds libuv, built-in modules (fs, http, etc.), npm integration, and the module loader.
When should I scale horizontally vs vertically?
Scale pods/replicas horizontally for I/O-bound APIs. Add workers or bigger CPUs vertically when profiling shows CPU-bound hotspots on the main thread.
How do I measure event loop health?
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log('p99 loop delay (ns):', h.percentile(99));
h.reset();
}, 10_000);Does Node 24 change the core model?
The V8 + libuv + event loop model is unchanged. Node 24 ships newer V8, stable fetch, and improved node:test - but the threading model is the same.
Why use Node instead of a threaded server?
Non-blocking I/O excels for high-concurrency, I/O-bound workloads (APIs, proxies, streaming). CPU-heavy batch jobs may fit better on runtimes that default to thread pools.
Can native addons block the main thread?
Yes, if they call back into JavaScript synchronously or run long sync code in the addon. Profile with --trace-sync-io when investigating mysterious stalls.
Related
- Event Loop Basics - call stack and task queues in practice
- libuv Phases - timers, poll, check, and close phases
- worker_threads - CPU-bound work off the main thread
- Detecting Event-Loop Blockage - production lag metrics
- Node.js Basics - first commands and patterns
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.