How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.
Node.js is not a runtime that hides its mechanics well. Once you understand how it actually schedules work, why some async calls are truly non-blocking while others secretly block a thread, and what happens to microtasks between event loop ticks, you can reason about performance problems you otherwise blame on the wrong thing.
This article walks through the full execution model: V8’s compilation pipeline, libuv’s six-phase event loop, the thread pool, the microtask priority system, and how module loading fits in before your first line of user code runs.
V8: From Source to Machine Code
Node.js does not interpret JavaScript line by line. V8 compiles it through two stages.
The first stage is Ignition, V8’s bytecode interpreter. When a function is first encountered, V8 compiles the source to compact bytecode and starts executing it. This is fast to compile and cheap on memory, but not as fast to run as machine code.
The second stage is TurboFan, V8’s optimizing compiler. Ignition collects type feedback as bytecode executes: what shapes objects have, what types flow through call sites, what branches are always taken. TurboFan uses this feedback to generate optimized native machine code for hot functions, making assumptions like “this argument is always a number.”
When those assumptions break (a different type shows up), V8 deoptimizes: the optimized code is thrown out and execution falls back to Ignition. Repeated deoptimizations on a hot path are a real source of performance regressions in Node.js services.
// This function gets optimized by TurboFan assuming `n` is always a number
function add(a: number, b: number): number {
return a + b;
}
// Calling it with a string triggers deoptimization
add(1, 2); // optimized path
add(1, "2"); // deopt — TurboFan's assumption violated
V8 also runs a garbage collector in the same process. The heap is divided into young generation (short-lived allocations, collected frequently with minor GC) and old generation (objects that survived several collections, collected less often with major GC). Major GC pauses the main thread, which shows up as event loop lag spikes.
The libuv Event Loop: Six Phases
The event loop is the core scheduling mechanism. It is implemented in libuv, a C library that handles all async I/O, timers, and cross-platform OS abstractions. JavaScript runs in a single thread. libuv is what makes non-blocking I/O possible without multiple JS threads.
The loop runs continuously until there are no more pending handles or requests. Each iteration of the loop processes exactly these phases in order:
┌────────────────────────────────┐
│ timers │ setTimeout, setInterval callbacks
└──────────────┬─────────────────┘
│
┌──────────────▼─────────────────┐
│ pending callbacks │ I/O callbacks deferred from prev tick
└──────────────┬─────────────────┘
│
┌──────────────▼─────────────────┐
│ idle / prepare │ internal libuv use only
└──────────────┬─────────────────┘
│
┌──────────────▼─────────────────┐
│ poll │ retrieve new I/O events; block here
└──────────────┬─────────────────┘
│
┌──────────────▼─────────────────┐
│ check │ setImmediate callbacks
└──────────────┬─────────────────┘
│
┌──────────────▼─────────────────┐
│ close callbacks │ socket.on('close'), etc.
└──────────────┴─────────────────┘
Timers phase. Executes callbacks for setTimeout and setInterval whose threshold has expired. The threshold is the minimum delay, not a guarantee. If the poll phase takes longer than expected, timers fire late.
Pending callbacks phase. Runs I/O callbacks that were deferred to the next loop iteration by the previous poll phase (e.g., some TCP error callbacks on Linux).
Idle/prepare phase. Used internally by libuv. You will not schedule work here from user code.
Poll phase. This is where Node.js spends most of its time. The poll phase retrieves new I/O events from the OS (via epoll on Linux, kqueue on macOS, IOCP on Windows) and executes their callbacks. If the callback queue is empty and there are no timers expiring soon, the loop blocks here waiting for I/O activity. This is what makes Node.js efficient at I/O-heavy workloads: threads are not blocked, the process just waits at the OS level.
Check phase. Runs setImmediate callbacks. setImmediate always fires after the current poll phase completes, regardless of any pending timers. This makes it predictable for scheduling work after I/O.
Close callbacks phase. Handles close events, such as socket.destroy().
Timer Ordering Is Not Strict
A common misconception: setTimeout(fn, 0) and setImmediate(fn) do not have a defined ordering when both are queued from the main module. Inside an I/O callback, setImmediate always fires first because the loop is already past the timers phase.
import { readFile } from "fs";
readFile("/etc/hostname", () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// Output is always: immediate, then timeout
// Because inside an I/O callback, check phase comes before next timers phase
});
The Thread Pool: Where Blocking Operations Go
Node.js is single-threaded for JavaScript, but not for everything. libuv maintains a thread pool (default size: 4 threads) for operations that do not have async OS-level support or that block predictably.
Operations that use the thread pool:
- File system calls (
fs.readFile,fs.stat,fs.writeFile, etc.) - DNS lookups via
dns.lookup()(notdns.resolve(), which uses the OS async resolver) - Crypto operations (
crypto.pbkdf2,crypto.scrypt,crypto.randomFill) - Zlib compression
- User-defined work via
worker_threadsor native addons
When the thread pool is saturated, file I/O callbacks start queuing behind each other. Four concurrent fs.readFile calls run in parallel. The fifth waits for a thread. This is a non-obvious bottleneck that does not look like CPU pressure.
import { pbkdf2 } from "crypto";
import { promisify } from "util";
const pbkdf2Async = promisify(pbkdf2);
// With UV_THREADPOOL_SIZE=4 (default), 5 concurrent calls means
// one waits. At login time with many concurrent requests, this stacks up.
async function hashPassword(password: string): Promise<Buffer> {
return pbkdf2Async(password, "salt", 100_000, 64, "sha512");
}
You can increase the pool size with the UV_THREADPOOL_SIZE environment variable (max 1024, though beyond the number of available CPU cores diminishing returns set in quickly):
UV_THREADPOOL_SIZE=16 node server.js
For CPU-bound work, the right tool is worker_threads, which gives true parallelism by spawning V8 isolates in separate threads with message-passing over MessageChannel.
Microtask Queue Priority: nextTick and Promises
Between each phase transition, and between each callback within a phase, Node.js drains two microtask queues before proceeding:
process.nextTickqueue (higher priority)- Promise microtask queue (resolved
.then,catch,finally,awaitcontinuations)
The ordering is strict: all pending nextTick callbacks run before any Promise microtasks. Both queues are fully drained before the event loop moves to the next phase.
setTimeout(() => console.log("timer"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
// Output:
// nextTick
// promise
// timer (or immediate, ordering is non-deterministic from main module)
// immediate
This has a sharp edge: recursive process.nextTick calls can starve the event loop entirely. If a nextTick callback schedules another nextTick, Node.js keeps draining that queue forever and never moves to the next phase.
// Do NOT do this — it starves I/O callbacks
function recursiveNextTick() {
process.nextTick(recursiveNextTick);
}
recursiveNextTick();
async/await Desugaring to Microtasks
async/await is syntactic sugar over Promises. Every await expression suspends the current function, schedules the continuation as a microtask when the awaited Promise resolves, and returns control to the caller.
async function fetchData(url: string): Promise<string> {
const response = await fetch(url); // suspends here
const text = await response.text(); // suspends again
return text;
}
This desugars roughly to:
function fetchData(url: string): Promise<string> {
return fetch(url).then((response) => {
return response.text().then((text) => {
return text;
});
});
}
Each .then continuation is a microtask. When fetch resolves, the microtask queue receives the first continuation. After it runs, if response.text() resolves synchronously (it won’t, but hypothetically), the second continuation is queued immediately. Both execute before any pending I/O callbacks in the current event loop phase.
This means await does not yield to I/O. If you have a loop with many await calls that resolve immediately, the event loop stays in the microtask queue and I/O callbacks pile up.
// This holds the event loop in microtasks for N iterations
// before any I/O callbacks can run
async function tightLoop(items: number[]): Promise<void> {
for (const item of items) {
await Promise.resolve(item); // resolves immediately, but still a microtask
}
}
If you need to yield to I/O inside a long async loop, use setImmediate wrapped in a Promise:
function yieldToEventLoop(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
async function friendlyLoop(items: number[]): Promise<void> {
for (const item of items) {
process(item);
if (/* every N items */ items.indexOf(item) % 100 === 0) {
await yieldToEventLoop();
}
}
}
Module Resolution: require() vs import()
CommonJS require()
require() is synchronous. When Node.js encounters require('./foo'), it:
- Resolves the path: tries
./foo.js,./foo.json,./foo.node, then./foo/index.js - Checks the module cache (
require.cache). If found, returns the cachedexportsobject immediately. - Reads the file synchronously from disk
- Wraps it in a module wrapper function:
(function(exports, require, module, __filename, __dirname) { ... }) - Executes it synchronously
- Caches
module.exportsunder the resolved path - Returns
module.exports
The synchronous nature means require() at the top of a file blocks the event loop while Node.js resolves and executes every dependency in the tree. For large dependency trees, this is visible as startup latency. It is also why you should not call require() inside hot paths.
The module cache is a flat map keyed by resolved absolute path. Circular dependencies are handled by returning the partially-constructed exports object at the point of the cycle, which can produce undefined values if not accounted for.
ESM import()
ES modules use a different loader. Static import statements are analyzed at parse time, not execution time. Node.js builds a module graph, fetches all dependencies concurrently (each as an async operation), evaluates them in post-order (dependencies before dependents), and only then starts executing the entry module.
Dynamic import() is always async and returns a Promise:
// Static import — resolved at parse time, synchronous-looking but async under the hood
import { readFile } from "fs/promises";
// Dynamic import — explicit async, returns Promise<Module>
const { default: config } = await import("./config.js");
ESM and CommonJS interop has sharp edges. ESM can import CJS modules (the module.exports becomes the default export), but CJS cannot require() an ESM module because require() is synchronous and ESM evaluation is async. In practice this means migrating a large CJS codebase to ESM incrementally requires careful sequencing.
Production Considerations
Event Loop Lag Monitoring
Event loop lag is the delta between when a timer was scheduled and when its callback actually executed. It is a proxy for how blocked the loop is. Measure it by scheduling a high-frequency timer and comparing actual versus expected fire times:
function measureEventLoopLag(intervalMs = 100): () => number {
let lag = 0;
let lastTime = process.hrtime.bigint();
const interval = setInterval(() => {
const now = process.hrtime.bigint();
const delta = Number(now - lastTime) / 1_000_000; // nanoseconds to ms
lag = Math.max(0, delta - intervalMs);
lastTime = now;
}, intervalMs);
interval.unref(); // don't keep the process alive for this
return () => lag;
}
Expose this as a metric. Lag above 50ms consistently indicates event loop saturation. The most common causes are synchronous CPU work in request handlers, unthrottled Promise microtask loops, and thread pool exhaustion causing I/O callbacks to queue.
Thread Pool Saturation
Monitor thread pool pressure by timing fs operations. If median fs.stat latency is 1ms but p99 is 200ms, the thread pool is saturated. Tune UV_THREADPOOL_SIZE to match your I/O concurrency, and move CPU-bound work to worker_threads.
async function timedStat(path: string): Promise<{ result: Stats; durationMs: number }> {
const start = performance.now();
const result = await stat(path);
return { result, durationMs: performance.now() - start };
}
Heap Tuning
V8’s default max old generation heap size is derived from available memory (roughly 1.5GB on a 64-bit system with 8GB RAM). For services that handle large in-memory datasets, set it explicitly:
node --max-old-space-size=4096 server.js # 4GB old generation
Do not set this blindly higher. Larger heaps mean longer major GC pauses when they do occur. A better approach is to profile with --inspect and node --heap-prof to understand what is actually staying alive.
Avoiding Synchronous Traps in Hot Paths
Several Node.js APIs have synchronous counterparts that are sometimes used out of habit. In a server context, these block the event loop for every request:
// Never in a request handler:
import { readFileSync, existsSync } from "fs";
// Always prefer:
import { readFile } from "fs/promises";
// DNS: dns.lookup() uses the thread pool, dns.resolve() uses async kernel resolver
import { resolve } from "dns/promises"; // async, does not use thread pool
Tradeoffs: Node.js vs the Alternatives
| Node.js | Deno | Bun | Cloudflare Workers | Go | |
|---|---|---|---|---|---|
| Concurrency model | Single-threaded event loop + libuv thread pool | Same event loop model (V8) | Same event loop (JavaScriptCore) | Isolates per request, no shared state | Goroutines, M:N threading |
| Startup time | ~50-150ms (module loading) | ~80-200ms | ~5-20ms | <1ms (V8 isolate reuse) | ~1-5ms |
| TypeScript support | Needs transpile step (tsc, tsx, ts-node) | Native, no config | Native, no config | Native via esbuild | N/A (separate ecosystem) |
| Package management | npm / pnpm / yarn, node_modules | URL imports + npm: specifiers | Bundles npm compat natively | npm via wrangler | Go modules |
| File I/O | Thread pool, mature API | Thread pool, permissions model | Thread pool, Node.js-compatible | No direct FS access | Blocking in goroutine (fine) |
| Memory model | Shared process heap, GC pauses | Same | Same | Bounded per isolate (~128MB) | Manual allocator + GC, lower overhead |
| Sweet spot | HTTP APIs, streaming, tooling, long-running servers | Scripting, CLIs, serverless with npm compat | Fast CLIs, build tools, Node.js drop-in replacement | Edge functions, low-latency global APIs | CPU-bound services, high concurrency without GC pressure |
Bun’s headline startup advantage matters most for CLI tools and short-lived scripts. For long-running HTTP servers, startup time is paid once; what matters is throughput and event loop efficiency, where Node.js, Deno, and Bun are roughly comparable because they share the same architectural model.
Cloudflare Workers’ sub-millisecond cold start comes from V8 isolate reuse across requests in the same process, not a fundamentally different runtime. The tradeoff is a constrained execution environment: no filesystem, limited CPU per request, no long-running connections.
Go’s goroutine model genuinely scales differently. For CPU-bound workloads or services that need thousands of concurrent long-lived connections, Go’s M:N threading avoids the single-thread-per-event-loop ceiling. The cost is a larger cognitive model and no JavaScript ecosystem.
Closing
The event loop is not magic and it is not hidden. It has six named phases, two microtask queues, and a thread pool of configurable depth. Once you have that model, most Node.js performance problems become legible: timer drift is poll phase blocking, high p99 on file operations is thread pool saturation, unexplained latency spikes during load are GC pauses, and CPU-bound work in a request handler is an event loop monopolization problem.
The runtime is well-suited to what it was designed for. Where it breaks down, the model tells you why and points you toward the right tool.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Next.js Works Internally: The Compilation Pipeline, RSC Protocol, and Caching Architecture From Request to Render
A deep dive into the internal architecture of Next.js App Router. Covers the SWC/Turbopack compilation pipeline, the RSC wire protocol, the four-layer caching architecture, rendering strategies, middleware execution, Server Actions, and the self-hosted vs managed deployment tradeoffs.