Node.js runtime: V8, libuv and the event loop
What is Node.js actually made of, and what decides whether my code blocks every other request?
Node.js is V8 (the JavaScript engine) plus libuv (an event loop and a small thread pool) glued together by C++ bindings. Your JavaScript runs on one thread; I/O is handed to the kernel or the thread pool, and its callbacks come back through the event loop's phases.
THE MENTAL MODEL
Picture one cashier (the main thread running V8) and a back office (the kernel's epoll/kqueue/IOCP plus four libuv worker threads). The cashier never waits on a socket or a disk: it files the request, serves the next customer, and picks up the result when the event loop hands it back in a fixed order of phases (timers, pending, poll, check, close). Between every callback, Node drains process.nextTick and then promise microtasks. Anything that keeps the cashier busy, such as a sync fs call, a 50 MB JSON.parse, a catastrophic regex or a tight loop, stalls every connection at once. Anything that ties up the four workers (fs, dns.lookup, async crypto, zlib) quietly queues the others. Streams exist so data moves in bounded chunks instead of whole buffers, and backpressure is how a slow consumer tells a fast producer to stop.
HOW IT FITS TOGETHER
- Your JavaScript / TypeScriptApp code and npm packages. Runs on the single main thread unless you start worker_threads or child processes.
- Node.js APIs (node:*)node:http, node:fs, node:stream, node:crypto, node:test, plus web globals such as fetch, AbortController, URL and Web Streams.
- C++ bindingsTranslate JS calls into libuv requests and V8 handles. Native addons (Node-API) plug in here too.
- V8 engine + libuvV8: parser, JIT, garbage collector, microtask queue. libuv: event loop, timers, sockets, 4-thread pool for fs, dns.lookup, crypto, zlib.
- Operating systemepoll (Linux), kqueue (macOS/BSD), IOCP (Windows) for non-blocking sockets; ordinary blocking syscalls inside pool threads for files.
- Main module runsTop-level code executes synchronously, registering timers, sockets and listeners. Then the loop starts.
- timersCallbacks from setTimeout/setInterval whose threshold has passed. The delay is a minimum, not a guarantee.
- pending callbacksSome system I/O callbacks deferred from the previous turn, such as certain TCP errors.
- idle, prepareInternal to libuv; not visible from JavaScript.
- pollFetch new I/O events and run their callbacks (sockets, completed fs work). Blocks here waiting for I/O when nothing else is due.
- checksetImmediate callbacks. Always after poll, so inside an I/O callback setImmediate beats setTimeout(0).
- close callbacks'close' events such as socket.on('close') after an abrupt destroy().
- Anything still alive?Active handles (servers, sockets, ref'd timers) or requests keep the loop going; otherwise the process exits.
| Runs on | Blocks | Prefer | |
|---|---|---|---|
| fs.readFileSync, *Sync APIs | Main thread | Loop, for the whole call | Async fs at runtime; Sync only at startup |
| fs.promises / fs callbacks | libuv thread pool | Pool slot until done | Streams for large files |
| net, http, fetch sockets | Kernel epoll/kqueue/IOCP | Nothing | Bound concurrency and timeouts anyway |
| dns.lookup (default resolver) | libuv thread pool (getaddrinfo) | Pool slot; slow DNS starves fs | dns.resolve* (c-ares) or keep-alive reuse |
| crypto.pbkdf2 / scrypt async | libuv thread pool | Pool slot | Async form; never pbkdf2Sync in a handler |
| zlib.gzip vs zlib.gzipSync | Pool vs main thread | Pool vs loop | createGzip() in a pipeline |
| Huge JSON.parse / stringify | Main thread (V8) | Loop, proportional to size | Cap body size; stream-parse; offload |
| Catastrophic regex, CPU loops | Main thread (V8) | Loop, possibly unbounded | Safe regexes; worker_threads for CPU work |
KEY TERMS
- V8 and libuv
- V8 compiles and runs JavaScript, owns the heap and the promise microtask queue, and knows nothing about files or sockets. libuv provides the event loop, timers, non-blocking sockets via the OS, and a thread pool for work the OS cannot do asynchronously. Node's bindings connect them.
- Event loop phases
- timers → pending callbacks → idle/prepare → poll → check → close callbacks, repeated while handles are alive. Since libuv 1.45 (Node 20) timers run only after poll, so code that relied on timers firing before the first poll can change order.
- process.nextTick and microtasks
- Not phases. After each callback Node drains the nextTick queue, then promise jobs (queueMicrotask, .then, await continuations). Recursive nextTick or an endless promise chain starves I/O. In ES modules the order differs: the module body already runs inside a microtask, so promises can log before nextTick. Prefer queueMicrotask.
- Thread pool
- Four threads by default (UV_THREADPOOL_SIZE, set before the process starts). Used by all async fs APIs, dns.lookup, async crypto such as pbkdf2, scrypt and randomBytes, and async zlib. Network sockets do not use it.
- Streams and backpressure
- Readable, Writable, Duplex and Transform move data in chunks of highWaterMark (64 KiB default for byte streams since Node 22). write() returning false means stop until 'drain'. stream/promises pipeline() wires this up, propagates errors, destroys every stream on failure and accepts an AbortSignal.
- ESM, CommonJS and require(esm)
- "type": "module" or .mjs means ESM; otherwise .js is CommonJS. require() of a synchronous ES module works without a flag since 22.12 and 20.19, and is no longer experimental as of 25.4. A module graph with top-level await throws ERR_REQUIRE_ASYNC_MODULE; use import() there.
- Built-ins that replaced dependencies
- Stable on the current LTS line (Node 24): global fetch (undici), node:test with mocks and timers, --watch, --env-file and process.loadEnvFile, node --run for package.json scripts, and type stripping for erasable TypeScript (stable since 24.12). Node 26 is the Current line and becomes the next LTS.
IN YOUR STACK
JavaScript · node:stream/promises Server
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
// Async generator transform: each yield waits until downstream has room.
async function* redactEmails(source) {
let tail = "";
for await (const chunk of source) {
const lines = (tail + chunk).split("\n");
tail = lines.pop(); // keep a partial line for the next chunk
for (const line of lines) yield line.replace(/[^,\s]+@[^,\s]+/g, "<redacted>") + "\n";
}
if (tail) yield tail;
}
await pipeline(
createReadStream("export.csv", { encoding: "utf8" }),
redactEmails,
createGzip(),
createWriteStream("export.csv.gz"),
{ signal: AbortSignal.timeout(60_000) },
);
console.log("done, memory stayed flat");- pipeline() handles backpressure, forwards the first error and destroys every stream; chained .pipe() does neither of the last two.
- Never split a byte chunk at an arbitrary point and decode it: pass an encoding (or use setEncoding) so multi-byte characters are not cut in half.
- A Web ReadableStream (for example a fetch response.body) can be a pipeline source too.
JavaScript · node:fs + node:perf_hooks Server
import { readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { monitorEventLoopDelay } from "node:perf_hooks";
const lag = monitorEventLoopDelay({ resolution: 20 });
lag.enable();
createServer(async (req, res) => {
if (req.url === "/sync") {
// Main thread waits on the disk: every other request waits too.
const body = readFileSync("big.json");
return res.end(body);
}
if (req.url === "/async") {
// Read happens on a libuv pool thread; the loop keeps serving.
return res.end(await readFile("big.json"));
}
res.end(JSON.stringify({ p99LagMs: lag.percentile(99) / 1e6 }));
}).listen(3000);- Load-test /sync and /async with a few hundred concurrent connections and watch p99 event-loop lag: the sync route drags every route with it.
- Sync fs is fine at startup (config, certificates) before the server accepts traffic.
- readFile still buffers the whole file in memory; for large files stream it with createReadStream and pipeline.
JavaScript · CommonJS (order.cjs) Server
const { readFile } = require("node:fs");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
// sync, nextTick, promise, then timeout/immediate in EITHER order:
// from the main module it depends on how fast the loop starts.
readFile(__filename, () => {
setTimeout(() => console.log("io: timeout"), 0);
setImmediate(() => console.log("io: immediate"));
// Always "io: immediate" first: check comes right after poll,
// timers only on the next turn.
});- Ported to .mjs (import, import.meta.filename), "promise" prints before "nextTick": an ES module body already runs inside a microtask.
- Use setImmediate to yield to I/O between chunks of CPU work; nextTick does not yield, it runs before the loop continues.
- The browser event loop has no nextTick or setImmediate; see the JavaScript event loop explainer.
JavaScript · node:test Server
// retry.test.mjs — run: node --test (or node --test --watch)
import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
function retryLater(fn, delayMs) {
return new Promise((resolve) => setTimeout(() => resolve(fn()), delayMs));
}
describe("retryLater", () => {
it("waits for the delay without real time passing", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const fn = mock.fn(() => "ok");
const result = retryLater(fn, 30_000);
assert.equal(fn.mock.callCount(), 0);
t.mock.timers.tick(30_000);
assert.equal(await result, "ok");
assert.equal(fn.mock.callCount(), 1);
});
});- node:test is stable; mock.fn, mock.method, mock.timers, snapshots and reporters ship with Node, so small libraries need no Jest or Vitest.
- Coverage via --experimental-test-coverage is still experimental; so is test watch mode.
- Without a path, node --test finds *.test.*, *-test.*, *_test.*, test-*.* and files under test/, including .ts/.mts/.cts while type stripping is on.
JavaScript · AbortSignal + fetch Client
import { setTimeout as sleep } from "node:timers/promises";
async function getJson(url, { signal, timeoutMs = 5_000 } = {}) {
// Abort on the caller's signal OR our own deadline, whichever comes first.
const signals = [AbortSignal.timeout(timeoutMs)];
if (signal) signals.push(signal);
const res = await fetch(url, { signal: AbortSignal.any(signals) });
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
return res.json();
}
async function pollUntilReady(url, signal) {
for (let attempt = 0; ; attempt++) {
const job = await getJson(url, { signal });
if (job.status === "ready") return job;
await sleep(Math.min(1_000 * 2 ** attempt, 15_000), undefined, { signal });
}
}
const shutdown = new AbortController();
process.once("SIGTERM", () => shutdown.abort());
console.log(await pollUntilReady("https://api.example.com/jobs/42", shutdown.signal));- AbortSignal.timeout rejects with a TimeoutError DOMException; a manual abort() rejects with AbortError. Branch on err.name to tell them apart.
- Global fetch has no default overall timeout; without a signal a hung upstream holds the request open.
- timers/promises setTimeout and setInterval accept a signal, so sleeps inside retry loops end promptly on shutdown.
JavaScript · node:http Server
import { createServer } from "node:http";
let draining = false;
const server = createServer((req, res) => {
if (req.url === "/ready") return res.writeHead(draining ? 503 : 200).end();
if (draining) res.setHeader("Connection", "close"); // no keep-alive reuse
res.end(JSON.stringify({ ok: true }));
}).listen(3000);
async function closeResources() {
// await pool.end(); await consumer.disconnect(); flush logs...
}
process.once("SIGTERM", async () => {
draining = true; // readiness probe fails, load balancer stops routing
const force = setTimeout(() => server.closeAllConnections(), 10_000);
force.unref(); // do not keep the process alive just for this timer
// Stops accepting; idle keep-alive sockets close; waits for in-flight ones.
await new Promise((resolve) => server.close(resolve));
await closeResources();
process.exitCode = 0; // let the loop empty instead of process.exit()
});- Installing a SIGTERM listener removes Node's default exit-on-SIGTERM, so your handler must actually finish the shutdown.
- Keep the force timeout below the orchestrator's grace period (Kubernetes defaults to 30 s before SIGKILL).
- If the process does not exit after this, something still holds the loop alive: an interval, an open socket or a pool. why-is-node-running or --trace-exit help find it.
Shell + TypeScript · node CLI (Node 24 LTS) Server
# Run TypeScript directly (erasable syntax only), reload on change, load .env
node --watch --env-file-if-exists=.env src/server.ts
# package.json "scripts" without npm's overhead
node --run dev
# Tests, including .ts test files, through the built-in runner
node --test
// src/server.ts — fine under type stripping:
import type { IncomingMessage } from "node:http";
import { createServer } from "node:http";
interface Health { ok: boolean; uptime: number }
const health = (_req: IncomingMessage): Health => ({ ok: true, uptime: process.uptime() });
createServer((req, res) => res.end(JSON.stringify(health(req)))).listen(Number(process.env.PORT ?? 3000));
// Not fine: enum, namespaces with values, parameter properties, decorators,
// tsconfig "paths". Use import type for type-only imports.- Type stripping replaces types with whitespace and never type-checks: keep tsc --noEmit in CI, and set erasableSyntaxOnly and verbatimModuleSyntax in tsconfig to catch unsupported syntax early.
- --env-file and --env-file-if-exists became stable in 24.10; --watch has been stable since 22.0.
- Node ignores tsconfig.json entirely, and does not strip types inside node_modules.
WHERE IT BITES
- Assuming async means free: a burst of dns.lookup calls (every fetch to a new host), bcrypt or pbkdf2 hashes, or large zlib jobs fills the four pool threads and makes unrelated fs reads slow. Measure, then size UV_THREADPOOL_SIZE in the environment, not via process.env at runtime.
- Doing CPU work on the main thread: big JSON bodies, report generation, image resizing or regexes on user input. Cap request sizes, watch event-loop delay, and move CPU-bound work to worker_threads or another service.
- Using readable.pipe() without error handling: an error in one stream leaves the others open and leaks file descriptors. Use pipeline() from node:stream/promises.
- Ignoring write() returning false in hand-written producers, which lets a slow client or disk grow process memory without bound.
- Calling process.exit() in signal handlers or after logging: it can truncate stdout and skip in-flight work. Set process.exitCode and let the loop drain.
- Mixing module systems blindly: require() of an ESM package that uses top-level await throws ERR_REQUIRE_ASYNC_MODULE, and __dirname does not exist in ESM (use import.meta.dirname).
CLOSE THE AI. EXPLAIN THIS.
A Node API's p99 latency spikes whenever a nightly export runs in the same process, even though every handler is async. Name three places the export could be blocking (main thread, thread pool, memory) and which tool or API would confirm each one.SOURCES
- Node.js: The Node.js Event Loop (opens in new tab)Checked
- Node.js: Don't Block the Event Loop (or the Worker Pool) (opens in new tab)Checked
- Node.js API: Stream (opens in new tab)Checked
- Node.js API: Modules (CommonJS), loading ES modules with require() (opens in new tab)Checked
- Node.js API: Modules: TypeScript (opens in new tab)Checked
- Node.js release schedule (opens in new tab)Checked
Explainer reviewed