Module 2: Node.js Runtime & Architecture

Internal Architecture

Node.js is not an independent language or framework; it is an open-source, cross-platform JavaScript runtime environment built on Chrome’s V8 engine and libuv.

+-------------------------------------------------------+
|                   Node.js Bindings                    |
|          (node:fs, node:http, node:crypto...)         |
+-------------------------------------------------------+
|               Node.js C++ Core & Addons               |
+---------------------------+---------------------------+
|         V8 Engine         |           libuv           |
|  - Compiles JS to Machine |  - Event Loop             |
|    Code (JIT)             |  - libuv Thread Pool      |
|  - Call Stack             |  - Epoll / Kqueue / IOCP  |
|  - Memory Heap (GC)       |  - Cross-platform Async   |
+---------------------------+---------------------------+
|          c-ares           |  OpenSSL  |  zlib  |  llhttp
+---------------------------+---------------------------+
|                 Operating System Kernel               |
+-------------------------------------------------------+

The V8 Engine

Written in C++, Google’s V8 engine compiles JavaScript directly into native machine code before executing it, using an optimizing JIT (Just-In-Time) compiler pipeline.

  • Call Stack: Executes synchronous JavaScript instructions via LIFO (Last In, First Out). Since JavaScript is single-threaded, there is only one Call Stack on the main thread.
  • Memory Heap: Allocates memory for objects, closures, and variables. Garbage Collection (GC) sweeps this area automatically (using generational garbage collection algorithms).

libuv

libuv is a C library that handles the platform abstraction layer for asynchronous I/O. It provides:

  1. The Event Loop: Orchestrates non-blocking execution across different task queues.
  2. The Worker Thread Pool: Handles operations that cannot be completed non-blockingly at the OS level.
  3. Cross-Platform System Polling: Interfaces directly with native kernel event mechanisms:
    • Linux: epoll
    • macOS / BSD: kqueue
    • Windows: IOCP (Input/Output Completion Ports)

The libuv Thread Pool

Node.js executes JavaScript on a single thread. However, low-level tasks that have no asynchronous OS system calls are delegated to the libuv thread pool.

Tasks handled by the thread pool include:

  • All File System Operations (fs): Operating systems generally do not support true non-blocking disk I/O.
  • DNS Resolution: Specifically dns.lookup() (which uses synchronous getaddrinfo(3)).
  • CPU-Heavy Cryptography: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes().
  • Compression: zlib operations.

Thread Pool Configuration: The default thread pool size is 4. You can increase it up to 1024 by setting the environment variable before the runtime initializes:

Bash

UV_THREADPOOL_SIZE=16 node server.js

Note: Network I/O (HTTP requests, incoming TCP sockets, WebSockets) does not use the thread pool. The kernel directly notifies libuv when network packets arrive, allowing a single thread to handle tens of thousands of concurrent connections.

Non-Blocking I/O Workflow

  1. A JavaScript function calls an asynchronous API (e.g., fs.readFile() or https.get()).
  2. The Node.js C++ binding hands the request over to libuv.
  3. If it’s a network socket, libuv registers a file descriptor with the OS kernel (epoll/kqueue). If it’s disk I/O, it pushes the work to the thread pool.
  4. The JavaScript Call Stack is immediately freed; the runtime does not block.
  5. When the OS or thread pool finishes the task, libuv enqueues the associated callback into the corresponding Event Loop phase.

Event Loop Phases

The Event Loop is an infinite loop managed by libuv that processes queued callbacks. Each iteration of this loop is called a tick.

   ┌──────────────────────────────────────────┐
┌─>│                 timers                   │
│  └────────────────────┬─────────────────────┘
│  ┌────────────────────┴─────────────────────┐
│  │             pending callbacks            │
│  └────────────────────┬─────────────────────┘
│  ┌────────────────────┴─────────────────────┐
│  │               idle, prepare              │
│  └────────────────────┬─────────────────────┘
│  ┌────────────────────┴─────────────────────┐
│  │                   poll                   │ <── Incoming connections,
│  └────────────────────┬─────────────────────┘     data, I/O callbacks
│  ┌────────────────────┴─────────────────────┐
│  │                   check                  │ <── setImmediate()
│  └────────────────────┬─────────────────────┘
│  ┌────────────────────┴─────────────────────┐
│  │              close callbacks             │
└──┴────────────────────┴─────────────────────┘
PhaseDescription & Responsibilities
1. TimersExecutes callbacks scheduled by setTimeout() and setInterval() whose threshold has expired.
2. Pending CallbacksExecutes I/O callbacks deferred from the previous loop iteration (e.g., system-level errors like ECONNREFUSED reported by TCP sockets).
3. Idle, PrepareInternal phase used strictly by libuv for internal subsystem housekeeping. JavaScript callbacks do not run here.
4. PollRetrieves new I/O events. If callbacks are queued in this phase, it runs them until the queue is empty or a system-dependent limit is reached. If the queue is empty, the loop either blocks here waiting for incoming I/O, or immediately advances to the check phase if setImmediate() callbacks are scheduled.
5. CheckDedicated phase for executing callbacks registered with setImmediate().
6. Close CallbacksHandles sudden connection drops and cleanup events (e.g., socket.on('close', ...), process.exit()).

setTimeout(fn, 0) vs setImmediate(fn)

  • When invoked inside an I/O cycle (e.g., inside an fs.readFile callback), setImmediate always executes first because the poll phase moves directly to the check phase.
  • When invoked from the global scope (outside an I/O cycle), the execution order is non-deterministic and depends on process performance and OS timer resolution:

JavaScript

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('Timer 1'), 0);
  setImmediate(() => console.log('Immediate 1'));
  // Output is always:
  // Immediate 1
  // Timer 1
});

Microtask Queue vs. Macrotask Queue

The Event Loop phases manage Macrotasks (Timers, I/O, setImmediate). However, Node.js also maintains two distinct Microtask Queues that sit outside the libuv loop.

       [ Call Stack Empties ]
                 │
                 ▼
    ┌───────────────────────────┐
    │  process.nextTick Queue   │ <── Highest Priority
    └────────────┬──────────────┘
                 │ (Drained completely)
                 ▼
    ┌───────────────────────────┐
    │    Promise Microtasks     │ <── Resolved Promises, queueMicrotask()
    └────────────┬──────────────┘
                 │ (Drained completely)
                 ▼
    [ Advance to Next Event Loop Phase ]

The Priority Hierarchy

  1. process.nextTick() Queue: Evaluated immediately after the currently running operation completes on the Call Stack, regardless of which phase the Event Loop is currently in.
  2. Promise Microtask Queue: Evaluates resolved Promise chains (.then(), .catch(), .finally()) and queueMicrotask(). Runs immediately after the nextTick queue is fully drained.
  3. Macrotask Queue: Evaluates timer callbacks, poll events, or check events depending on the active Event Loop phase.

Priority Execution Verification

JavaScript

const fs = require('fs');

console.log('1. Synchronous Start');

setTimeout(() => console.log('2. setTimeout (Macrotask - Timers)'), 0);
setImmediate(() => console.log('3. setImmediate (Macrotask - Check)'));

Promise.resolve().then(() => {
  console.log('4. Promise Microtask');
});

process.nextTick(() => {
  console.log('5. process.nextTick Microtask');
});

console.log('6. Synchronous End');

// Execution Order:
// 1. Synchronous Start
// 6. Synchronous End
// 5. process.nextTick Microtask   (nextTick queue drains first)
// 4. Promise Microtask            (Promise microtask queue drains second)
// 2. setTimeout                   (Event loop enters Timers phase)
// 3. setImmediate                (Event loop reaches Check phase)

Warning: Recursively calling process.nextTick() starves the Event Loop by preventing it from ever advancing to the next phase, causing complete I/O starvation.

Global Objects & CLI Execution

Node.js provides a set of globally available objects, references, and execution primitives that do not require explicit module imports.

Key Globals

  • global: The root global namespace object (analogous to window in web browsers). Attaching properties to global makes them accessible everywhere, but this is an anti-pattern that leads to hidden dependencies and memory retention.
  • process: Provides control and diagnostic information over the running Node.js process:
    • process.env: Exposes system environment variables.
    • process.argv: Array containing command-line arguments.
    • process.cwd(): Returns the Current Working Directory from which the process was launched (different from the file location).
    • process.exit(code): Terminates the process (0 for success, non-zero for failure).
    • process.memoryUsage(): Returns an object describing heap allocation, resident set size (RSS), and external memory.

File Resolution Identifiers (CommonJS Only)

  • __dirname: The absolute directory path of the file currently being executed.
  • __filename: The absolute path including the filename of the file currently being executed.

JavaScript

// Given a file at /apps/backend/src/server.js:
console.log(__dirname);  // Output: /apps/backend/src
console.log(__filename); // Output: /apps/backend/src/server.js
console.log(process.cwd()); // If run from /apps/backend -> Output: /apps/backend

CLI Execution Primitives

Node.js includes powerful CLI flags for debugging, inspection, and memory configuration:

Bash

# Start an interactive Read-Eval-Print Loop
node

# Execute inline JavaScript without creating a file
node -e "console.log(crypto.randomUUID())"

# Start process with the V8 Inspector attached (debug via Chrome DevTools or VS Code)
node --inspect index.js

# Break immediately at the first line of code on startup
node --inspect-brk index.js

# Override V8 maximum old generation heap memory (e.g., set to 4GB)
node --max-old-space-size=4096 index.js

# Watch mode (automatically restarts on file changes - built-in since Node v18+)
node --watch server.js

Module Systems: CommonJS vs. ES Modules

Node.js supports two distinct module architectures: CommonJS (CJS) and ECMAScript Modules (ESM).

+---------------------------+-----------------------------------------------+
| CommonJS (CJS)            | ES Modules (ESM)                              |
+---------------------------+-----------------------------------------------+
| Default legacy system     | Modern standard (ECMAScript specification)    |
| Synchronous loading       | Asynchronous parsing and loading              |
| Dynamic requires allowed  | Static imports by default                     |
| Evaluated at runtime      | Parsed before evaluation (Static Analysis)    |
| Node.js specific          | Cross-platform (Browser + Node.js)            |
+---------------------------+-----------------------------------------------+

Syntax Comparison

CommonJS (.cjs or default in standard package.json)

JavaScript

// Exporting: math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

module.exports = { add, subtract };
// Alternative: exports.add = add;

// Importing: app.js
const { add, subtract } = require('./math');
console.log(add(5, 2));
ES Modules (.mjs or with "type": "module" in package.json)

JavaScript

// Exporting: math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export default function multiply(a, b) { return a * b; }

// Importing: app.js
import multiply, { add, subtract } from './math.js'; // Extension required
console.log(multiply(5, 2));

Deep Architectural Differences

1. Module Wrapper Function (CommonJS)

Under the hood, before executing any CJS file, Node.js wraps its content inside an IIFE (Immediately Invoked Function Expression):

JavaScript

(function(exports, require, module, __filename, __dirname) {
  // Your file code actually lives here
});

This is why variables like exports, require, module, __filename, and __dirname appear global in CJS when they are actually locally scoped parameters.

2. Absence of __dirname and __filename in ESM

In ES Modules, the wrapper function is not used, so __dirname and __filename do not exist. You reconstruct them using import.meta.url:

JavaScript

import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
3. Loading Mechanism: Synchronous vs. Asynchronous
  • CommonJS: require() is synchronous. It stops execution, reads the file from the disk, compiles it, caches it, and returns the exported object. You can dynamically call require() anywhere (inside if statements or loops).
  • ES Modules: Import references are hoisted and resolved statically before the code is evaluated. This enables tree-shaking (removing unused code). Dynamic loading in ESM is achieved using the asynchronous import() function:

JavaScript

// Dynamic import inside an ESM file
if (process.env.NODE_ENV === 'development') {
  const devTools = await import('./devTools.js');
  devTools.init();
}
4. Caching & References

Both systems cache loaded modules on the first call. However, they pass values differently:

  • CommonJS: Exports a copied value (or reference copy for objects). Modifying an exported primitive variable inside the source module after export does not update the imported value in the consumer.
  • ES Modules: Exports a live binding. If the source module changes the value of an exported variable, the importer sees the updated value immediately.