1. Introduction ๐
Node.js is a JavaScript runtime that lets you run JavaScript outside the browser, on servers, desktops, and even microcontrollers. It powers everything from REST APIs to real-time chat apps and command-line tools.
This tutorial walks through Node's internals โ from the V8 engine to the event loop โ building a solid mental model you can rely on when debugging performance issues or designing scalable systems.
Information
2. What is the Node.js Runtime? ๐ฑ
A runtime is the environment in which a program executes. Browsers provide a JavaScript runtime that includes the DOM, window, and fetch. Node.js replaces those browser APIs with server-oriented ones โ file system access, networking, and process control โ while reusing the same V8 JavaScript engine that powers Google Chrome.
- Built on V8, Google's open-source JavaScript engine.
- Adds an event-driven, non-blocking I/O layer via libuv.
- Ships with a standard library: fs, http, path, crypto, and more.
- Originally created in 2009 by Ryan Dahl.
Tip
3. Node.js Architecture ๐๏ธ
Node's architecture combines several layers that work together to execute JavaScript efficiently while handling I/O asynchronously.
3.1 JavaScript Runtime Environment
The runtime environment is everything available to your code beyond the JavaScript language itself: global objects, modules, timers, and I/O primitives. Node's runtime environment is intentionally different from a browser's โ there's no window, but there is a process object.
3.2 V8 Engine
V8 is Google's JavaScript and WebAssembly engine, written in C++. It compiles JavaScript directly to machine code using JIT compilation, and manages memory allocation and garbage collection for JS objects.
- Ignition: V8's interpreter, produces bytecode quickly.
- TurboFan: V8's optimizing compiler for hot code paths.
- Handles the Heap (objects) and Call Stack (execution frames).
3.3 libuv
libuv is a C library that gives Node its asynchronous, event-driven superpowers. It abstracts platform-specific I/O (epoll on Linux, kqueue on macOS, IOCP on Windows) behind a single consistent API.
Important
4. Event-Driven Programming ๐
Node.js is built around events. Instead of blocking while waiting for something (a file read, a network request), Node registers a callback and moves on, invoking that callback when the event fires.
event-emitter-example.js
const EventEmitter = require('events');
const emitter = new EventEmitter();
emitter.on('greet', (name) => {
console.log(`Hello, ${name}!`);
});
emitter.emit('greet', 'World');4.1 Non-Blocking I/O
In a blocking model, each I/O operation halts execution until it completes. Node instead uses non-blocking I/O: operations like reading a file are delegated to libuv, and execution continues immediately โ the result arrives later via a callback, promise, or async/await.
| Model | Behavior | Throughput |
|---|---|---|
| Blocking I/O | Waits for each operation to finish | Low under concurrency |
| Non-blocking I/O | Delegates and continues immediately | High under concurrency |
4.2 Single-Threaded Execution
JavaScript in Node runs on a single main thread. This simplifies programming (no shared-memory race conditions in your JS code) but means CPU-heavy synchronous work blocks everything else, including incoming requests.
Warning
5. The Event Loop ๐
The event loop is the mechanism that allows Node to perform non-blocking I/O despite JavaScript being single-threaded. It continuously checks the call stack, various queues, and pending I/O, deciding what runs next.
5.1 Call Stack
The call stack tracks function calls using a LIFO structure. When a function is invoked, a frame is pushed; when it returns, the frame is popped. The event loop only picks up new work when the call stack is empty.
5.2 Callback Queue
Also called the macrotask queue's I/O sibling, this queue holds callbacks from completed asynchronous operations (timers, I/O) waiting for the call stack to clear before they execute.
5.3 Microtask Queue
The microtask queue holds Promise callbacks (.then, .catch, .finally) and queueMicrotask() callbacks. Crucially, the entire microtask queue is drained after every single call stack operation โ before the event loop moves to the next phase.
microtask-vs-macrotask.js
console.log('1: sync');
setTimeout(() => console.log('4: macrotask'), 0);
Promise.resolve().then(() => console.log('3: microtask'));
console.log('2: sync');
// Output order: 1, 2, 3, 45.4 Macrotask Queue
Macrotasks include setTimeout, setInterval, setImmediate, and I/O callbacks. Only one macrotask is processed per event loop tick, and the microtask queue is fully drained before and after it.
5.5 Thread Pool
Some operations โ file system access, DNS lookups (dns.lookup), certain crypto functions โ aren't natively async at the OS level on all platforms. libuv handles these using a thread pool, defaulting to 4 threads, configurable via UV_THREADPOOL_SIZE.
Best Practice
5.6 Worker Threads Overview
The worker_threads module lets you run actual JavaScript in parallel threads with independent V8 instances โ useful for CPU-intensive tasks (image processing, complex calculations) that would otherwise block the main thread.
worker-example.js
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
const worker = new Worker(__filename);
worker.on('message', (msg) => console.log('From worker:', msg));
} else {
parentPort.postMessage('Hello from worker thread!');
}6. Global Objects & Variables ๐
6.1 Global Objects
Node exposes several objects globally without requiring an import or require:
- global โ the top-level namespace object (analogous to window in browsers).
- process โ information and control over the current Node process.
- Buffer โ for handling raw binary data.
- console โ logging utilities.
6.2 Global Variables
Some identifiers look global but are actually module-scoped โ injected per-file by Node's module wrapper:
module-wrapper.js
(function (exports, require, module, __filename, __dirname) {
// Your module code lives here
});- __dirname โ absolute path of the current directory.
- __filename โ absolute path of the current file.
- require and module โ only in CJS files.
7. Module System ๐ฆ
7.1 Overview
Node supports two module systems: the original CommonJS (CJS) and standardized ES Modules (ESM). Understanding both โ and their differences โ is essential for modern Node development.
7.2 CommonJS
7.3 ES Modules
math.js (CJS)
function add(a, b) {
return a + b;
}
module.exports = { add };index.js (CJS)
const { add } = require('./lib/math');
console.log(add(2, 3));math.mjs (ESM)
export function add(a, b) {
return a + b;
}index.mjs (ESM)
import { add } from './lib/math.mjs';
console.log(add(2, 3));| Feature | CommonJS | ES Modules |
|---|---|---|
| Syntax | require / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| File extension | .js / .cjs | .mjs or "type": "module" |
| __dirname | Available | Not available (use import.meta.url) |
8. Runtime & Process Lifecycle โ๏ธ
8.1 Runtime Lifecycle
A Node program's lifecycle starts by executing the entry file top to bottom, registering callbacks, timers, and listeners. The process stays alive as long as there's something in the event loop to process; once the queues are empty and no handles remain, Node exits naturally.
8.2 Process Lifecycle
The global process object exposes lifecycle events and controls:
process-lifecycle.js
process.on('exit', (code) => {
console.log(`About to exit with code: ${code}`);
});
process.on('SIGINT', () => {
console.log('Received SIGINT (Ctrl+C)');
process.exit(0);
});Caution
9. Memory Management ๐ง
V8 divides memory into the stack (primitive values, function call frames) and the heap (objects, closures, arrays). Node processes have a default heap size limit, configurable via the --max-old-space-size flag.
9.1 Garbage Collection
V8's garbage collector reclaims memory occupied by objects no longer reachable from the root. It uses a generational strategy:
- Scavenge (Minor GC): fast, frequent collection of the young generation (short-lived objects).
- Mark-Sweep-Compact (Major GC): slower collection of the old generation (long-lived objects).
Example
9.2 Runtime Performance
Common levers for improving Node performance include reducing synchronous work on the main thread, streaming large payloads instead of buffering them fully, and profiling with tools like --prof or the clinic toolkit.
10. Best Practices โ
- Prefer async/await over deeply nested callbacks for readability.
- Never block the event loop with heavy synchronous computation โ offload to worker_threads or a queue.
- Always handle Promise rejections; unhandled rejections can crash the process.
- Use streams for large files instead of reading them entirely into memory.
- Keep dependencies updated and audit them with npm audit.
Best Practice
11. Common Mistakes โ ๏ธ
- Using synchronous fs methods (e.g. fs.readFileSync) in request handlers.
- Assuming setTimeout(fn, 0) runs immediately โ it still waits for the current stack and microtasks.
- Forgetting that require caches modules, leading to unexpected shared state.
- Mixing CommonJS and ES Modules without understanding interop rules.
- Not handling backpressure when working with streams.