Skip to content
intermediate Phase 9 · Backend with Node.js

Node.js Fundamentals

Understand the event loop, modules (CJS/ESM), streams, file system, and process management.

1h 15m
0 problems
Topic Progress 0%

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?

Question 1 options

2. What is the key difference between process.nextTick() and setImmediate()?

Question 2 options

3. When should you use worker_threads instead of child_process?

Question 3 options

Flashcards

Question

What are the three main phases of the Node.js event loop?

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?

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?

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:

  1. Timers: setTimeout, setInterval callbacks
  2. Pending Callbacks: system-level callbacks (e.g., TCP errors)
  3. Poll: incoming I/O events
  4. Check: setImmediate callbacks
  5. 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);