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)

Table of Contents

  1. Architectural Overview & Core Schema
  2. Deep Dive & Implementation
  3. Anti-Patterns & Common Pitfalls
  4. Independent Challenge
  5. Consolidation & Key Invariants
  6. 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

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:

  1. Schedules a setTimeout(fn, 0) and a setImmediate callback from the main module context
  2. Inside each of those callbacks, chains a Promise.resolve().then() callback
  3. Uses process.nextTick in at least one place
  4. Prints a complete trace of every callback fired and its execution phase (e.g., sync, nextTick, microtask, poll, check, close)
  5. 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.


6. Next Steps

Sequenced links to dependent lessons, deeper dives, or production guides.


Change Log

All meaningful modifications should be tracked here with a date and session context.

2026-08-29

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse