Node.js Runtime — V8 and the Server-Side JavaScript Engine
Status: Active | Last Updated: 2026-08-29 Category: Runtime — Node.js Prerequisites: TypeScript basics Tags: node, v8, npm, libuv, event-loop, cjs, esm Estimated Time: 3–4 hours (Self-paced, includes lab time)
Summary
Node.js brought JavaScript to the server. It uses Google's V8 engine (the same engine Chrome uses) plus a library called libuv that handles asynchronous I/O. This article explains how Node works, how its package manager npm differs from bun and pnpm, and when to choose Node over Bun.
What You'll Learn (Core Competencies)
- Trace the execution path from JavaScript source to machine code in Node (V8 + libuv)
- Explain and predict the ordering of synchronous, microtask, and macrotask callbacks in the event loop
- Choose correctly between CommonJS (
require) and ESM (import) for a given project context - Compare npm, pnpm, and yarn on disk usage, install speed, and workspace support
- Make an evidence-based decision between Node and Bun for a specific workload
- Profile a Node.js process using
--profand Chrome DevTools
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
Foundational theory, system mechanics, and high-level design.
What is Node.js
Node.js is a JavaScript runtime built on V8 (the same engine in Chrome) plus libuv, a C library for asynchronous I/O. Before Node, JavaScript only ran in browsers. Node gave it file system access, networking, and other server-side capabilities. It was created by Ryan Dahl in 2009 with the specific goal of building a server that could handle thousands of concurrent connections with minimal overhead. Today, Node powers a massive portion of the web — from express APIs to serverless platforms like AWS Lambda.
Node's event-driven architecture means a single process can serve thousands of concurrent connections. The key is that I/O operations (reading files, making HTTP requests, querying databases) are non-blocking. When an I/O operation starts, the main thread continues executing other code. When the I/O completes, a callback is queued and executed by the event loop.
// Event-loop example: concurrent file reads
import { readFile } from "node:fs/promises";
async function loadConfig() {
// These three reads happen concurrently, not sequentially
const [db, api, env] = await Promise.all([
readFile("database.json", "utf8"),
readFile("api-routes.json", "utf8"),
readFile("env.local", "utf8"),
]);
return { db: JSON.parse(db), api: JSON.parse(api), env: env.trim() };
}
loadConfig().then(console.log);
V8 and libuv
- V8 compiles JavaScript to machine code at runtime (JIT compilation)
- libuv handles the event loop, file I/O, network I/O, threading
When you call fs.readFile(), Node hands the work to libuv, which uses a thread pool, and Node's main thread continues running other code. When the file is read, libuv queues a callback. Node's event loop picks it up on the next tick.
// libuv thread-pool under the surface
import fs from "node:fs";
// This is non-blocking — the event loop continues
fs.readFile("large-file.csv", (err, data) => {
if (err) throw err;
console.log("File loaded, length:", data.length);
});
console.log("This runs BEFORE the file finishes loading");
The thread pool size can be tuned with UV_THREADPOOL_SIZE (default 4). Increasing it helps with CPU-heavy file operations but consumes more system resources. For production systems with heavy file I/O, consider using streams instead of full-file reads.
The Event Loop
The event loop is the mechanism that allows Node to perform non-blocking I/O operations by offloading operations to the system kernel whenever possible. It has phases: timers, pending callbacks, idle/prepare, poll, check, and close callbacks.
// Demonstrating phases — timers fire after poll
setTimeout(() => console.log("timer 1"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("microtask"));
console.log("main thread");
// Output order: main → microtask → timer/immediate (order depends on context)
Microtasks (Promise callbacks, queueMicrotask) run before any queued macrotasks (setTimeout, setInterval, I/O callbacks). Understanding this order is essential when mixing await with setTimeout or when building custom scheduling libraries.
Module Systems: CJS vs ESM
Node supports both CommonJS (require) and ECMAScript Modules (import). Understanding when to use each is critical for compatibility and performance.
// CommonJS (CJS) — synchronous, runs at require time
const path = require("node:path");
const config = require("./config.json");
// ESM — statically analyzable, supports top-level await in modules
import { readFile } from "node:fs/promises";
import config from "./config.json" assert { type: "json" };
CJS modules are loaded synchronously — when you call require(), the module is evaluated immediately. ESM modules are parsed statically and loaded asynchronously. This means import can reference modules that have not yet been fully evaluated, while require() always blocks until the dependency resolves.
For new projects, prefer ESM ("type": "module" in package.json). For libraries that need broad compatibility, provide both (exports field with require and import conditions). Bun supports both without configuration.
Package Managers: npm, pnpm, yarn
| Feature | npm | pnpm | yarn | bun |
|---|---|---|---|---|
| Lockfile | package-lock.json |
pnpm-lock.yaml |
yarn.lock |
bun.lock |
| Install speed | Baseline | 2–3x faster | 2–4x faster | 5–10x faster |
| Disk usage | High (duplicates) | Low (content-addressable) | Medium | Low |
| Workspace support | npm workspaces | pnpm workspaces | Yarn workspaces | Built-in |
| Native TypeScript | No | No | No | Yes |
pnpm uses a content-addressable store — packages installed once are hard-linked across projects, reducing disk usage. npm creates isolated node_modules trees for each project, which is safer but uses more space. bun install takes a different approach: it uses its own binary lockfile format (bun.lockb) and resolves dependencies in parallel, which is why it is consistently faster than the alternatives.
# npm — baseline
npm install
npm run build
# pnpm — faster, disk-efficient
pnpm install
pnpm run build
# Bun — fastest, native TypeScript
bun install
bun run build
For this project, bun is preferred for development speed, but pnpm is a safe fallback when Bun compatibility issues arise with a particular package.
When to Choose Node
Choose Node when maximum ecosystem compatibility is required, when deploying to platforms that only support Node (some legacy serverless environments), or when your team has deep Node expertise. Node also provides finer-grained control over V8 tuning (e.g., --max-old-space-size) and has more mature profiling and debugging tools for complex production issues.
For new development in this stack, start with Bun. If you hit a compatibility issue with a package that relies on native V8 APIs or node-gyp-compiled modules, switch to Node for that service. The APIs (fs, path, http, net) are identical, so the switch is usually a matter of changing the runtime binary rather than rewriting code.
2. Deep Dive & Implementation
Technical implementation, fully commented code blocks, and step-by-step logic.
The Event Loop — Phase by Phase
The event loop in Node.js (powered by libuv) cycles through distinct phases on every tick:
while (running) {
runTimers() // setTimeout, setInterval callbacks
runPendingCallbacks() // I/O callbacks deferred from previous tick
runIdle() // internal: prepare, poll bookkeeping
runPoll() // retrieve new I/O events; executes I/O callbacks
runCheck() // setImmediate callbacks (fires after poll)
runClose() // close event callbacks (socket.on('close'))
// After every phase, drain the ENTIRE microtask queue
runMicrotasks() // Promise.then, queueMicrotask, MutationObserver
}
The critical implication: microtasks drain after every phase, not just at the end of the loop. This means await continuations and Promise.then callbacks fire before the next setTimeout or I/O callback, even if that timer was already scheduled.
async function demonstrateOrder() {
console.log("sync start");
setTimeout(() => console.log("macrotask: setTimeout"), 0);
setImmediate(() => console.log("macrotask: setImmediate"));
Promise.resolve().then(() => console.log("microtask: Promise.then"));
queueMicrotask(() => console.log("microtask: queueMicrotask"));
console.log("sync end");
// Output: sync start, sync end, microtask: Promise.then, microtask: queueMicrotask,
// macrotask: setTimeout, macrotask: setImmediate (or reversed — see below)
}
demonstrateOrder();
Note: The order of setTimeout(fn, 0) vs setImmediate varies depending on whether the call originates from the main module or from an I/O callback. Inside an I/O cycle (e.g., inside fs.readFile's callback), setImmediate always fires first.
CJS vs ESM — Deep Dive
The "type" field in package.json controls the default module system:
{
"type": "module" // all .js files are ESM
}
Without "type": "module", .js files are CommonJS by default. You can use .mjs for ESM and .cjs for CJS regardless of the package type setting.
// config.json — works in both CJS and ESM
// CJS
const cfg = require("./config.json");
// ESM — uses assert syntax (Node 17+) or with import assertions
import cfg from "./config.json" with { type: "json" };
// Dual-library pattern — exports field with both conditions
// package.json:
{
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}
Worker Threads
For CPU-bound work, Node's main thread is single-threaded. The Worker class spawns a separate V8 instance with its own event loop:
// main.ts
import { Worker } from "node:worker_threads";
const worker = new Worker(new URL("./compute.js", import.meta.url), {
workerData: { limit: 40 }
});
worker.on("message", (result) => {
console.log("Fibonacci result:", result);
});
worker.on("error", (err) => {
console.error("Worker error:", err);
});
// compute.js
import { parentPort, workerData } from "node:worker_threads";
function fib(n: number): number {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
const result = fib(workerData.limit);
parentPort?.postMessage(result);
Run with node main.ts. Note that SharedArrayBuffer enables zero-copy communication for large data sets between workers, at the cost of requiring correct Atomics usage to avoid data races.
Guided Checkpoint
Verify your Node.js environment is fully operational with all checks below.
# 1. Confirm Node and npm versions
node --version
npm --version
# 2. Verify the event loop order prediction
# Run this script and predict the output before looking at it
node -e "
console.log('sync');
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('microtask'));
"
# 3. Check that node_modules resolves correctly for the repo
cd /home/agentic/fogserv.cloud
ls node_modules/.bin/tsc 2>/dev/null && echo "tsc found in node_modules" || echo "No local tsc"
# 4. Confirm UV_THREADPOOL_SIZE default behaviour
node -e "
const http = require('http');
console.log('Default UV_THREADPOOL_SIZE:', process.env.UV_THREADPOOL_SIZE ?? '4 (default)');
console.log('CPU count:', require('os').cpus().length);
"
3. Anti-Patterns & Common Pitfalls
Documented failure modes and how to detect/prevent them.
1. Accidental mixing of CJS and ESM top-level imports
When a project has "type": "module", all .js files are treated as ESM. A file that calls require() at the top level will throw ReferenceError: require is not defined. The reverse is also true: import at the top level in a CJS file throws a syntax error before the file even runs.
Detection: node app.js throws immediately on startup with a require/import error.
Prevention: Be explicit about file extensions. Use .cjs for CommonJS files and .mjs for ESM files when the package-level setting is ambiguous. Run node --check on each file before execution:
node --check src/utils.cjs # validates as CommonJS
node --check src/utils.mjs # validates as ESM
2. Blocking the event loop with synchronous I/O in request handlers
Node's single-threaded event loop handles all request processing. Any synchronous, CPU-heavy operation (crypto, JSON parsing of large bodies, synchronous file reads) blocks every other in-flight request.
Detection: Under load, p99 latency spikes while CPU usage stays moderate — the event loop is the bottleneck, not the hardware.
Prevention: Offload heavy work to Worker threads or use streaming/chunked processing. Profile with node --prof to confirm before optimising:
node --prof app.js
# Generate report
node --prof-process isolate-*.log > profile.txt
3. Setting UV_THREADPOOL_SIZE too high
The libuv thread pool handles file system operations, DNS lookups, and some crypto operations. Increasing UV_THREADPOOL_SIZE beyond the number of available CPU cores can increase context-switching overhead and memory usage without improving throughput.
Detection: Increasing the pool size past a certain point does not reduce I/O latency; process RSS grows. Prevention: Benchmark before and after. For most applications, a value between 4 and the CPU core count is optimal. For file-heavy workloads, prefer streams (which use the OS async I/O path directly) over thread-pool-backed reads.
4. Not handling uncaught promise rejections
In Node.js, an unhandled promise rejection that is not caught within the same tick does not throw synchronously — it is emitted as a process warning. In Node 15+, it causes the process to exit with a non-zero code, but only if there is no unhandledRejection handler registered.
Detection: Process exits with code 1 and the message "UnhandledPromiseRejectionWarning" in the logs. Prevention: Always add an exit handler during startup, especially in server code:
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
// In production: alert and gracefully shut down
process.exit(1);
});
5. Confusing setImmediate with process.nextTick
Both schedule callbacks "soon", but process.nextTick runs before the event loop continues to the next phase, while setImmediate runs in the check phase after I/O is polled. process.nextTick callbacks can starve the event loop if they recursively call themselves — always prefer setImmediate for scheduling work that should yield to I/O.
Detection: Adding a setTimeout(fn, 0) that fires before an expected process.nextTick callback.
Prevention: Use setImmediate as the default for scheduling work from within async callbacks; reserve process.nextTick only for cases where the callback must run before any I/O callback in the same tick.
6. Shipping TypeScript to production without type-checking
Node does not natively run TypeScript. If the project uses ts-node or tsx for development execution, production deployments that skip the compilation step will ship plain TypeScript that Node cannot parse, or will run transpiled output without any type safety guarantees.
Detection: ts-node works in development but the production Docker image fails to start with a syntax error.
Prevention: Separate the development runtime from the production build. Always run tsc --noEmit in CI, and use a proper build step (tsc --outDir dist) for production deployments.
4. Independent Challenge
A problem to solve without step-by-step guidance; promotes synthesis.
Challenge: Event loop tracing and microtask drain visualisation
Problem: Write a Node.js script (loop-trace.ts) that:
- Schedules a
setTimeout(fn, 0)and asetImmediatecallback from the main module context - Inside each of those callbacks, chains a
Promise.resolve().then()callback - Uses
process.nextTickin at least one place - Prints a complete trace of every callback fired and its execution phase (e.g.,
sync,nextTick,microtask,poll,check,close) - Predicts the total order before running the script, then confirms by running it
Additional requirement: Inside one of the macrotask callbacks, schedule a new setImmediate (nested). Verify that this nested setImmediate fires in the same event loop tick (the check phase) or the next one, and document which is correct.
Run with node loop-trace.ts (using tsx or after compiling with tsc).
No solution is provided. The goal is to build an accurate mental model of Node's event loop ordering through direct experimentation.
5. Consolidation & Key Invariants
Bullet-list summary of the must-remember rules.
- Microtasks drain after every phase —
Promise.then,queueMicrotask, andawaitcontinuations always run before the next macrotask, even if that macrotask was already scheduled. setImmediatevssetTimeout(fn, 0)has context-dependent ordering — both fire in the same tick when called from the main module, butsetImmediatealways fires beforesetTimeoutinside an I/O callback.process.nextTickruns before the event loop continues — it is not part of any phase and can starve I/O if abused; prefersetImmediatefor deferred work.- CommonJS (
require) is synchronous; ESM (import) is asynchronous — you cannot use top-levelrequireinside an ESM module, and you cannotawaitat the top level of a CommonJS module without an async IIFE. UV_THREADPOOL_SIZEtunes I/O parallelism — default is 4; increase for file-heavy workloads but benchmark the return; for truly heavy I/O, prefer streaming APIs that bypass the thread pool entirely.- Node is single-threaded for JavaScript — CPU-bound work requires Worker threads, not just async patterns; async does not parallelism, it concurrency.
- TypeScript in Node requires a build step —
ts-node/tsxare development conveniences only; CI and production must runtsc --noEmitand compile to JavaScript before execution.
6. Next Steps
Sequenced links to dependent lessons, deeper dives, or production guides.
- runtime-internals — Deep dive into event loops, Worker threads, and JIT compilation under both V8 and JavaScriptCore
- bun-runtime — Use Bun's native features for faster development workflows and built-in SQLite
- ../backend/http-api-design — Build REST APIs with Express or
node:httpand connect to databases - ../sql/sql-fundamentals — Query design, indexes, and transaction management for Node-based services
- V8 JavaScript engine documentation — Official docs on V8 internals, optimisation hints, and memory model
Change Log
All meaningful modifications should be tracked here with a date and session context.
2026-08-29
- Migrated to 6-section canonical template
- Expanded from stub to full lecture depth
- Added event-loop code example with
Promise.all - Added CJS vs ESM comparison with
import/require - Added package-manager comparison table and command examples
- Added Guided Checkpoint: event loop verification script
- Added Anti-Patterns section (6 failure modes with detection and prevention)
- Added Independent Challenge: event loop tracing and microtask drain visualisation
- Added Consolidation & Key Invariants (7 bullet rules)
- Updated Next Steps with full relative paths
- Aligned with
kb/containers/docker-concepts.mdformatting