Node.js Architecture and Event Loop
Node.js runs JavaScript on V8 with libuv handling asynchronous I/O. The event loop is the core mechanism that allows non-blocking execution despite JavaScript being single-threaded.
Event Loop Phases
The event loop executes in distinct phases, each with its own callback queue:
┌───────────────────────────┐
┌─>│ timers │←── setTimeout, setInterval callbacks
│ └──────────┬────────────────┘
│ ┌──────────┴────────────────┐
│ │ pending callbacks │←── system errors (e.g., ECONNREFUSED)
│ └──────────┬────────────────┘
│ ┌──────────┴────────────────┐
│ │ idle, prepare │←── internal use only
│ └──────────┬────────────────┘ ┌───────────────────┐
│ ┌──────────┴────────────────┐ │ incoming: │
│ │ poll │←─────┤ connections, data│
│ └──────────┬────────────────┘ └───────────────────┘
│ ┌──────────┴────────────────┐
│ │ check │←── setImmediate callbacks
│ └──────────┬────────────────┘
│ ┌──────────┴────────────────┐
└──┤ close callbacks │←── socket.on('close', ...)
└───────────────────────────┘
Practical Event Loop Example
console.log('1: Start');
setTimeout(() => console.log('2: setTimeout'), 0);
setImmediate(() => console.log('3: setImmediate'));
Promise.resolve().then(() => console.log('4: Promise resolve'));
process.nextTick(() => console.log('5: nextTick'));
console.log('6: End');
// Output order:
// 1: Start
// 6: End
// 5: nextTick (nextTick queue drains before next phase)
// 4: Promise resolve (microtask queue after nextTick)
// 2: setTimeout (timers phase)
// 3: setImmediate (check phase)
Microtasks vs Macrotasks
// Microtasks: process.nextTick, Promise.then — executed BEFORE next event loop phase
// Macrotasks: setTimeout, setImmediate, I/O callbacks — one per loop tick
const fs = require('fs');
fs.readFile(__filename, () => {
// This I/O callback is a macrotask
console.log('File read complete');
// Microtasks inside macrotask still drain fully
Promise.resolve().then(() => console.log('Microtask inside I/O'));
process.nextTick(() => console.log('nextTick inside I/O'));
});
// Microtasks execute before the I/O callback itself:
// nextTick inside I/O
// Microtask inside I/O
// File read complete
Modules: CommonJS and ES Modules
Node.js supports two module systems. CommonJS uses require() and module.exports, while ES Modules use import/export with static analysis.
CommonJS Module System
// math.js
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }
module.exports = { add, multiply };
module.exports.default = add; // default export
// app.js
const math = require('./math'); // full module
const { add } = require('./math'); // destructured
const addFn = require('./math').default; // default export
// Caching: require() caches modules after first load
// To force reload, delete from require.cache:
delete require.cache[require.resolve('./math')];
ES Modules
// utils.mjs (or set "type": "module" in package.json)
export const PI = 3.14159;
export function circleArea(r) { return PI * r * r; }
export default class Calculator {
add(a, b) { return a + b; }
}
// app.mjs
import Calculator, { PI, circleArea } from './utils.mjs';
const calc = new Calculator();
console.log(calc.add(2, 3));
console.log(circleArea(5));
// Dynamic imports (async, useful for code splitting)
const module = await import('./heavy-module.mjs');
module.default.init();
// __dirname workaround in ESM
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Module Resolution Algorithm
1. Core modules: require('fs') → built-in Node.js module
2. Relative paths: require('./foo') → resolves from current file
3. node_modules: require('lodash') → searches up directories
- /project/node_modules/lodash
- /project/node_modules/@scope/lodash
- /node_modules/lodash
- /node_modules/@scope/lodash
4. Package.json "exports" field (ESM) or "main" field (CJS)
Practical Module Pattern
// lib/database.js — Singleton pattern with CommonJS
let instance = null;
class Database {
constructor(config) {
this.config = config;
this.connection = null;
}
async connect() {
if (!this.connection) {
this.connection = await createConnection(this.config);
}
return this.connection;
}
}
module.exports = {
getInstance(config) {
if (!instance) {
instance = new Database(config);
}
return instance;
}
};
File System and Streams
Node.js provides a powerful stream API for processing data in chunks instead of loading entire files into memory.
Stream Types
const { Readable, Writable, Transform, Duplex } = require('stream');
// Readable: data source (fs.createReadStream, http.IncomingMessage)
// Writable: data sink (fs.createWriteStream, http.ServerResponse)
// Transform: readable + writable (gzip, crypto, zlib)
// Duplex: readable + writable (TCP socket)
Readable Stream — Custom Implementation
const { Readable } = require('stream');
class NumberStream extends Readable {
constructor(max) {
super({ objectMode: true });
this.current = 0;
this.max = max;
}
_read() {
if (this.current < this.max) {
this.push(this.current++);
} else {
this.push(null); // signal end of stream
}
}
}
const nums = new NumberStream(5);
nums.on('data', (chunk) => console.log(chunk));
nums.on('end', () => console.log('Stream complete'));
// Output: 0, 1, 2, 3, 4, Stream complete
Transform Stream — Compress and Encrypt
const { Transform } = require('stream');
class UppercaseTransform extends Transform {
_transform(chunk, encoding, callback) {
const uppercased = chunk.toString().toUpperCase();
this.push(uppercased);
callback(); // signal completion of this chunk
}
}
const { pipeline } = require('stream/promises');
const fs = require('fs');
await pipeline(
fs.createReadStream('input.txt'),
new UppercaseTransform(),
fs.createWriteStream('output.txt')
);
Backpressure Handling
const readable = fs.createReadStream('large-file.txt');
const writable = fs.createWriteStream('copy.txt');
// Without backpressure handling — may cause memory issues:
// readable.pipe(writable);
// With proper backpressure handling:
readable.on('data', (chunk) => {
const canContinue = writable.write(chunk);
if (!canContinue) {
readable.pause();
writable.once('drain', () => readable.resume());
}
});
readable.on('end', () => writable.end());
// Or use pipeline() which handles backpressure automatically
const { pipeline } = require('stream/promises');
await pipeline(readable, writable);
Process Management and Worker Threads
Node.js provides tools for running code outside the main event loop: child processes for running system commands and worker threads for CPU-intensive JavaScript.
Process Object
// Access process information
console.log(process.pid); // current process ID
console.log(process.env.NODE_ENV); // environment variables
console.log(process.memoryUsage()); // heapTotal, heapUsed, rss, external
console.log(process.cpuUsage()); // user, system CPU time
console.log(process.uptime()); // seconds since start
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received. Cleaning up...');
server.close(() => {
db.disconnect();
process.exit(0);
});
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection:', reason);
// Log to monitoring service, then exit
process.exit(1);
});
Child Processes
const { exec, spawn, fork } = require('child_process');
const { promisify } = require('util');
// exec: runs shell command, buffers output (max 1MB default)
const execAsync = promisify(exec);
const { stdout } = await execAsync('git log --oneline -5');
console.log(stdout);
// spawn: streams output, no shell by default (safer)
const child = spawn('ls', ['-la', '/tmp']);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
// fork: spawns new Node.js process with IPC channel
// child.js
const parentData = process.env.NODE_DATA; // from fork env
process.send({ result: 42 }); // send back to parent
// parent.js
const child = fork('./child.js', [], {
env: { ...process.env, NODE_DATA: 'important-data' }
});
child.on('message', (msg) => console.log('From child:', msg.result));
Worker Threads for CPU Work
// worker.js
const { parentPort, workerData } = require('worker_threads');
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const result = fibonacci(workerData.n);
parentPort.postMessage(result);
// main.js
const { Worker } = require('worker_threads');
function runFibonacci(n) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', {
workerData: { n }
});
worker.on('message', resolve);
worker.on('error', reject);
});
}
// Worker pool pattern
const { StaticPool } = require('workerpool');
const pool = new StaticPool({
size: 4,
worker: './worker.js'
});
const results = await Promise.all(
[30, 35, 40, 42].map(n => pool.exec('fibonacci', [n]))
);
console.log(results); // [832040, 9227465, 102334155, 267914296]
Cluster Mode
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
console.log(`Primary ${process.pid} forking ${numCPUs} workers`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died (${signal || code}). Restarting...`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end(`Worker ${process.pid}\n`);
}).listen(3000);
console.log(`Worker ${process.pid} started`);
}
Quiz
1. Which event loop phase executes setTimeout callbacks?
2. What is the key difference between process.nextTick() and setImmediate()?
3. When should you use worker_threads instead of child_process?
Flashcards
Question
What are the three main phases of the Node.js event loop?
Click to reveal answer
Answer
Timers (setTimeout/setInterval), Poll (I/O events), and Check (setImmediate). There are also Pending Callbacks, Idle/Prepare, and Close Callbacks phases.
Question
When should you use streams instead of reading files entirely into memory?
Click to reveal answer
Answer
Use streams when processing files larger than available memory, when you need to start processing before the full file is loaded, or when piping data through transformations. Streams process data chunk by chunk.
Question
What is the difference between fork() and spawn() in child_process?
Click to reveal answer
Answer
fork() is specifically for spawning new Node.js processes with built-in IPC communication via process.send() and process.on('message'). spawn() is for running any executable with optional stdin/stdout/stderr piping.
Revision Notes
Key Takeaways
- 1. The event loop is single-threaded but libuv uses a thread pool for I/O operations
- 2. process.nextTick() runs before I/O callbacks, setImmediate() runs in the check phase
- 3. Streams handle large data efficiently with backpressure support
- 4. Worker threads are preferred over child_process for CPU-intensive JavaScript tasks
- 5. Use cluster module to utilize all CPU cores for production HTTP servers
Interview Tips
- • Be able to draw and explain the event loop phases and their order
- • Know when to use streams vs buffering (memory constraints, data size)
- • Explain the difference between process.nextTick and setImmediate with examples
- • Discuss how you would handle graceful shutdown in a production server
- • Explain worker threads vs child processes trade-offs (shared memory vs isolation)
Cheat Sheet
Node.js Fundamentals Cheat Sheet
Event Loop Phases:
- Timers: setTimeout, setInterval callbacks
- Pending Callbacks: system-level callbacks (e.g., TCP errors)
- Poll: incoming I/O events
- Check: setImmediate callbacks
- Close Callbacks: socket.on('close')
Module Systems:
- CommonJS: require()/module.exports (synchronous, cached)
- ESM: import/export (static, async, tree-shakeable)
Stream Types:
- Readable: data source (read)
- Writable: data sink (write)
- Transform: modify data (read + write)
- Duplex: bidirectional (read + write, e.g., TCP)
Process Management:
- exec: shell command, buffers output
- spawn: streams output, no shell (safer)
- fork: Node.js process with IPC
- worker threads: CPU-intensive JS, shared memory
- cluster: multi-process HTTP server
Key Patterns:
// Graceful shutdown
process.on('SIGTERM', async () => {
await server.close();
await db.disconnect();
process.exit(0);
});
// Worker thread
const worker = new Worker('./task.js', { workerData });
worker.on('message', handleResult);
worker.on('error', handleError);