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:
- The Event Loop: Orchestrates non-blocking execution across different task queues.
- The Worker Thread Pool: Handles operations that cannot be completed non-blockingly at the OS level.
- Cross-Platform System Polling: Interfaces directly with native kernel event mechanisms:
- Linux:
epoll - macOS / BSD:
kqueue - Windows:
IOCP(Input/Output Completion Ports)
- Linux:
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:
zliboperations.
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.jsNote: 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
- A JavaScript function calls an asynchronous API (e.g.,
fs.readFile()orhttps.get()). - The Node.js C++ binding hands the request over to libuv.
- 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. - The JavaScript Call Stack is immediately freed; the runtime does not block.
- 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 │
└──┴────────────────────┴─────────────────────┘
| Phase | Description & Responsibilities |
| 1. Timers | Executes callbacks scheduled by setTimeout() and setInterval() whose threshold has expired. |
| 2. Pending Callbacks | Executes I/O callbacks deferred from the previous loop iteration (e.g., system-level errors like ECONNREFUSED reported by TCP sockets). |
| 3. Idle, Prepare | Internal phase used strictly by libuv for internal subsystem housekeeping. JavaScript callbacks do not run here. |
| 4. Poll | Retrieves 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. Check | Dedicated phase for executing callbacks registered with setImmediate(). |
| 6. Close Callbacks | Handles 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.readFilecallback),setImmediatealways 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
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.- Promise Microtask Queue: Evaluates resolved Promise chains (
.then(),.catch(),.finally()) andqueueMicrotask(). Runs immediately after thenextTickqueue is fully drained. - 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 towindowin web browsers). Attaching properties toglobalmakes 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 (0for 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 callrequire()anywhere (insideifstatements 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.
