Deep explanations, real code, mental models, common gotchas, and every interview question that actually gets asked β at Beginner, Intermediate, and Advanced levels.
Before writing a single line of code, you need to understand this. Everything else is built on top of it.
Your JavaScript code runs on one thread. There's only one call stack. Operations happen one at a time β but Node.js is not slow because of this.
Heavy I/O work (disk, DNS, crypto) is handed off to libuv's C++ thread pool (4 threads by default). The JS thread is free while libuv works in the background.
The Event Loop continuously checks: "Is the call stack empty? Are there callbacks ready?" If yes, it pushes them in. This is how thousands of async operations are managed.
Google's V8 compiles JavaScript to machine code (JIT). It handles garbage collection, memory management, and optimizations that make Node fast.
Not just "what" β but why it works, what happens under the hood, and what interviewers are actually testing.
Node.js has two module systems that work very differently. Understanding both is critical because most real codebases use one or the other, and mixing them causes painful bugs.
CommonJS (CJS) β the original Node module system. When you call require('./math'), Node reads the file, wraps it in a function, executes it, and returns whatever was assigned to module.exports. It's synchronous β the file is read and executed before the next line runs. This is fine for startup, but terrible inside an async operation.
ES Modules (ESM) β the JavaScript standard. Uses import/export. Key difference: ESM is statically analyzed at parse time, meaning the browser/Node can know all imports before any code runs. This enables tree-shaking (dead code elimination) and top-level await. Set "type": "module" in package.json, or use .mjs extension.
require() an ES module. And you can't use import inside a CJS file. They don't mix directly. Use dynamic import() to load ESM from CJS.// math.js β CommonJS export function add(a, b) { return a + b; } function multiply(a, b) { return a * b; } module.exports = { add, multiply }; // OR: exports.add = add; (same thing) // app.js β CommonJS import const { add, multiply } = require("./math"); const path = require("path"); // built-in const express = require("express"); // npm package console.log(add(2, 3)); // 5 // What require() actually does under the hood: // 1. Resolves file path // 2. Checks module cache (re-requires are fast!) // 3. Wraps in: (function(exports, require, module, __filename, __dirname) { ... }) // 4. Executes the wrapper // 5. Returns module.exports
// math.mjs β ES Module export export function add(a, b) { return a + b; } export const PI = 3.14159; // Named default export export default { add, PI }; // app.mjs β ES Module import import { add } from "./math.mjs"; // named import math from "./math.mjs"; // default import * as all from "./math.mjs"; // namespace import { createServer } from "node:http"; // built-in (node: prefix) // Top-level await β only available in ESM! const data = await fetch("https://api.example.com/data"); // package.json β enable ESM project-wide // { "type": "module" }
// Dynamic import() β works in both CJS and ESM // Use when you need to load a module conditionally async function loadPlugin(name) { const plugin = await import(`./plugins/${name}.mjs`); return plugin.default; } // In CJS β load an ES module this way: // const { add } = await import('./math.mjs'); β // const { add } = require('./math.mjs'); β throws! // Real use case: lazy load heavy libraries app.get("/pdf", async (req, res) => { const { generatePDF } = await import("./pdf-generator.mjs"); res.send(await generatePDF(req.body)); });
require()/exports and is synchronous β the whole file runs at require time. ESM uses import/export and is statically analyzed β imports are resolved before execution, enabling tree-shaking and top-level await. CJS is the Node.js default; ESM requires "type":"module" or .mjs extension.require() inside an if statement effectively?require() is synchronous so it blocks. For conditional loading, use dynamic import() which is async and doesn't block the event loop.required, Node caches it in require.cache. Subsequent require() calls return the cached object without re-executing the file. This means module-level state (like a database connection) is shared across all importers β a common pattern for singletons.The Event Loop is a continuous loop that runs while your Node process is alive. It has 6 phases, each with its own FIFO queue. The loop goes through each phase in order, processing callbacks from that queue before moving to the next.
setTimeout() and setInterval() whose threshold has passedsetImmediate() callbacks β always runs after poll, before timerssocket.on('close'), server.close()After each phase (and between individual callbacks within a phase), Node drains two microtask queues in order:
process.nextTick() is NOT part of the Event Loop phases. It runs immediately after the current operation completes, before the next phase. Calling nextTick recursively starves the event loop β I/O never gets a chance to run!console.log("1: sync β runs immediately"); process.nextTick(() => console.log("2: nextTick β before Promises")); Promise.resolve().then(() => console.log("3: Promise microtask")); setTimeout(() => console.log("5: setTimeout β macrotask"), 0); setImmediate(() => console.log("4: setImmediate β check phase")); console.log("6: sync β end of script"); // ACTUAL OUTPUT ORDER: // 1: sync β runs immediately // 6: sync β end of script // 2: nextTick β before Promises β microtask queue 1 // 3: Promise microtask β microtask queue 2 // 4: setImmediate β check phase β check phase (usually before setTimeout) // 5: setTimeout β macrotask β timers phase
// β BLOCKING β never do this in production app.get("/slow", (req, res) => { // This blocks the ENTIRE event loop for all users! const start = Date.now(); while (Date.now() - start < 2000) {} // 2 second CPU burn res.end("done"); }); // β Non-blocking β offload CPU work to Worker Thread const { Worker } = require("worker_threads"); app.get("/fast", (req, res) => { const worker = new Worker("./heavy-task.js"); worker.on("message", result => res.json(result)); }); // β fs.readFile is async β doesn't block app.get("/file", async (req, res) => { const data = await fs.readFile("./data.json", "utf-8"); // ^^ handed to libuv thread pool, event loop is FREE res.json(JSON.parse(data)); });
// setImmediate vs setTimeout(fn, 0) β the classic interview trick // Outside of I/O context: order is NON-DETERMINISTIC setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate")); // Could print either order β depends on system timer precision // Inside an I/O callback: setImmediate ALWAYS wins fs.readFile("file.txt", () => { setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate")); // β always first // Why: After I/O callback, we're in "poll" phase. // Next phase is "check" (setImmediate), THEN loop back to "timers" }); // process.nextTick vs setImmediate: choose carefully setImmediate(() => console.log("after I/O cycle")); process.nextTick(() => console.log("before I/O cycle")); // β runs first
process.nextTick() fires before the Event Loop moves to the next phase β it runs in the "microtask queue" after the current operation. setImmediate() fires in the "check" phase of the current loop iteration. Use nextTick for "before I/O" and setImmediate for "after I/O".Node.js evolved through three generations of async patterns. Understanding all three matters because you'll see all three in real codebases, and interviewers test your ability to convert between them and explain why each exists.
Callbacks (Node style): The original pattern. Functions take a callback as their last argument. By convention, the callback's first argument is an error (err). Called "error-first callbacks" or "Node-style callbacks". The problem: deep nesting creates "Callback Hell" β impossible to read, debug, or test.
Promises: A Promise is an object representing a future value. It's either pending, fulfilled, or rejected. Promises chain with .then() and .catch() β solving nesting but introducing a new mental model. Promise.all() runs multiple in parallel; Promise.allSettled() waits for all regardless of failure.
async/await: Syntactic sugar over Promises. An async function always returns a Promise. await pauses the function until the Promise resolves β but does NOT block the event loop; Node runs other code while waiting. Always wrap in try/catch β unhandled Promise rejections crash the process in Node 15+.
await as "take a number and sit down." The function pauses, but Node.js goes to serve other customers. When your async work is done, the Event Loop calls your number and resumes from where you left off.// β Callback Hell β "Pyramid of Doom" getUser(userId, (err, user) => { if (err) return handleError(err); getPosts(user.id, (err, posts) => { if (err) return handleError(err); getComments(posts[0].id, (err, comments) => { if (err) return handleError(err); saveReport({ user, posts, comments }, (err, result) => { if (err) return handleError(err); // 5 levels deep... π° console.log("Done:", result); }); }); }); });
// β Promises β flat chaining getUser(userId) .then(user => getPosts(user.id)) .then(posts => getComments(posts[0].id)) .then(comments => saveReport(comments)) .then(result => console.log("Done:", result)) .catch(err => handleError(err)); // ONE error handler for all // Creating a Promise from scratch function delay(ms) { return new Promise((resolve, reject) => { setTimeout(resolve, ms); // Call reject(err) to fail the promise }); } // Promisify a callback-based function const { promisify } = require("util"); const readFile = promisify(fs.readFile); const content = await readFile("file.txt", "utf-8");
// β async/await β reads like synchronous code async function loadDashboard(userId) { try { const user = await getUser(userId); const posts = await getPosts(user.id); const comments = await getComments(posts[0].id); const result = await saveReport({ user, posts, comments }); return result; } catch (err) { // Catches ALL errors from any await above console.error("Dashboard load failed:", err.message); throw err; // Re-throw if caller needs to know } } // Common mistake: forgetting await β no error catching! async function bad() { const data = fetchUser(); // β MISSING await! data = Promise, not value }
// Sequential (slow) β each awaits the previous const user = await getUser(id); // 100ms const profile = await getProfile(id); // 100ms const posts = await getPosts(id); // 100ms // Total: ~300ms // β Parallel (fast) β Promise.all starts all at once const [user, profile, posts] = await Promise.all([ getUser(id), getProfile(id), getPosts(id), ]); // Total: ~100ms (limited by slowest) // Promise.allSettled β don't fail if one rejects const results = await Promise.allSettled([fetchA(), fetchB()]); results.forEach(r => { if (r.status === "fulfilled") use(r.value); else logError(r.reason); }); // Promise.race β first to resolve/reject wins const data = await Promise.race([fetch(url), timeout(5000)]);
await suspends the current async function, but Node.js is free to run other code (other requests, callbacks, timers) while waiting. It's only blocking if you use synchronous operations like fs.readFileSync()..catch() or try/catch with await. Add a global safety net: process.on('unhandledRejection', handler).Promise.all when ALL promises must succeed β it rejects immediately if any fail (fail-fast). Use Promise.allSettled when you want to attempt all operations regardless β you get results for each, whether fulfilled or rejected. Example: refreshing multiple API feeds where partial success is acceptable.The topics that separate competent from excellent Node.js developers β and that senior interviews always probe.
Streams are one of Node.js's most powerful β and most misunderstood β features. A stream is an abstract interface for working with streaming data: data that arrives or is sent piece by piece (in chunks) rather than all at once.
Without streams: reading a 1GB video file means loading 1GB into RAM before doing anything. With streams: you read 64KB at a time, process it, and free the memory before the next chunk arrives. Your app uses ~64KB of RAM instead of 1GB.
pipe() handles this automatically β it pauses the Readable when the Writable's buffer is full and resumes when it drains. If you manually handle stream events without respecting backpressure, you'll get memory leaks.const fs = require("fs"); const zlib = require("zlib"); // β Stream a file β constant memory usage const readStream = fs.createReadStream("large-file.txt", { highWaterMark: 64 * 1024, // 64KB chunks (default) encoding: "utf-8" }); readStream.on("data", (chunk) => { console.log(`Received ${chunk.length} bytes`); }); readStream.on("end", () => console.log("Done")); readStream.on("error", (err) => console.error(err)); // pipe() β connects streams and handles backpressure // Read β Gzip compress β Write to file fs.createReadStream("input.txt") .pipe(zlib.createGzip()) .pipe(fs.createWriteStream("output.txt.gz")) .on("finish", () => console.log("Compressed!"));
const { Transform } = require("stream"); // Custom Transform: uppercase every chunk const upperCaseTransform = new Transform({ transform(chunk, encoding, callback) { // Push transformed data downstream this.push(chunk.toString().toUpperCase()); callback(); // signal "I'm done with this chunk" } }); // Chain: read β uppercase β write fs.createReadStream("input.txt") .pipe(upperCaseTransform) .pipe(fs.createWriteStream("output.txt")); // Real-world: JSON Lines transformer const jsonLinesParser = new Transform({ objectMode: true, // output objects, not buffers transform(chunk, enc, cb) { chunk.toString().split("\n") .filter(Boolean) .forEach(line => this.push(JSON.parse(line))); cb(); } });
// Streaming a large file as HTTP response // Without stream: reads entire file into memory, THEN sends // With stream: sends while reading β uses ~64KB RAM, not file size app.get("/download", (req, res) => { const filePath = "./huge-dataset.csv"; const stat = fs.statSync(filePath); res.setHeader("Content-Type", "text/csv"); res.setHeader("Content-Length", stat.size); // pipe the file directly into the response stream fs.createReadStream(filePath).pipe(res); }); // Streaming from a database (cursor-based) app.get("/users.ndjson", (req, res) => { res.setHeader("Content-Type", "application/x-ndjson"); const cursor = db.collection("users").find(); cursor.on("data", doc => res.write(JSON.stringify(doc) + "\n")); cursor.on("end", () => res.end()); });
pipe() automatically pauses the Readable when the Writable's internal buffer exceeds its highWaterMark, and resumes when it drains. If you bypass pipe(), you must manually check writable.write()'s return value and listen to the 'drain' event.Error handling in Node.js is a layered strategy. There are two types of errors, and you handle them very differently:
Operational errors: expected failures in a correct program. A network request times out, a file doesn't exist, a user sends invalid input, the database goes down. These should be caught and handled gracefully β return a 4xx/5xx, log them, maybe retry.
Programmer errors: bugs in your code. Calling a function with the wrong type, accessing undefined, off-by-one. These should crash the process and be fixed β don't try to recover; the process is in an unknown state. Use a process manager (PM2) to restart automatically.
catch(err) {} is the most dangerous pattern in Node.js. It hides bugs and makes debugging impossible. At minimum: catch(err) { console.error(err); throw err; }// Layer 1: try/catch for async functions async function getUser(id) { try { const user = await db.findById(id); if (!user) throw new NotFoundError(`User ${id} not found`); return user; } catch (err) { logger.error({ err, userId: id }, "getUser failed"); throw err; // always re-throw or handle completely } } // Layer 2: Unhandled Promise rejections process.on("unhandledRejection", (reason, promise) => { logger.fatal({ reason }, "Unhandled rejection"); process.exit(1); // let PM2/Docker restart us }); // Layer 3: Synchronous uncaught exceptions process.on("uncaughtException", (err) => { logger.fatal({ err }, "Uncaught exception β shutting down"); // Do NOT try to continue β state is unknown process.exit(1); });
// asyncHandler β wraps async routes to catch errors const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // β Routes stay clean app.get("/user/:id", asyncHandler(async (req, res) => { const user = await getUser(req.params.id); res.json(user); })); // if getUser throws, it goes to next(err) β error middleware // Global Express error middleware (4 params β REQUIRED) app.use((err, req, res, next) => { const status = err.statusCode || 500; const isOp = err instanceof OperationalError; logger.error({ err, url: req.url, method: req.method }); res.status(status).json({ error: isOp ? err.message : "Internal Server Error", // Never expose stack traces in production! ...(process.env.NODE_ENV !== "production" && { stack: err.stack }) }); });
// Custom error classes β know exactly what failed class AppError extends Error { constructor(message, statusCode, code) { super(message); this.statusCode = statusCode; this.code = code; this.isOperational = true; // expected error Error.captureStackTrace(this, this.constructor); } } class NotFoundError extends AppError { constructor(msg) { super(msg, 404, "NOT_FOUND"); } } class ValidationError extends AppError { constructor(msg) { super(msg, 400, "VALIDATION_ERROR"); } } class UnauthorizedError extends AppError { constructor(msg = "Unauthorized") { super(msg, 401, "UNAUTHORIZED"); } } // Usage if (!user) throw new NotFoundError(`User ${id} not found`); if (!token) throw new UnauthorizedError();
asyncHandler utility that calls next(err), or (2) manually catch and call next(err). The error then flows to your 4-argument error middleware. Express 5 (RC) handles this automatically.What separates senior engineers β deep internals, performance thinking, and real-world architectural decisions.
Node.js has three ways to run code in parallel, each for different use cases. Confusing them is a senior-level interview fail.
| Method | Separate process? | Shared memory? | Best for |
|---|---|---|---|
child_process.exec/spawn | β Yes | β No | Running shell commands, external programs |
child_process.fork | β Yes (Node.js) | β No (IPC only) | Spawning another Node.js script with messaging |
worker_threads | β Same process | β SharedArrayBuffer | CPU-heavy JS work (image processing, ML) |
cluster | β Yes (same port) | β No | Scaling HTTP server across all CPU cores |
const { exec, spawn } = require("child_process"); // exec β buffers output, good for small results exec("ls -la", (err, stdout, stderr) => { if (err) return console.error(err); console.log(stdout); }); // spawn β streams output, good for large/long-running const ls = spawn("ls", ["-la", "/usr"]); ls.stdout.on("data", data => process.stdout.write(data)); ls.stderr.on("data", data => console.error(data.toString())); ls.on("close", code => console.log(`Exited: ${code}`)); // β οΈ NEVER use exec with user input β shell injection risk! // exec(`ls ${userInput}`) β dangerous if userInput = "; rm -rf /" // spawn("ls", [userInput]) β safe β args are never shell-interpreted
// main.js β offload CPU work to a thread const { Worker, isMainThread, parentPort, workerData } = require("worker_threads"); if (isMainThread) { // Main thread β runs your web server const runTask = (data) => new Promise((resolve, reject) => { const w = new Worker(__filename, { workerData: data }); w.on("message", resolve); w.on("error", reject); w.on("exit", code => { if (code !== 0) reject(new Error(`Worker exited: ${code}`)); }); }); // Event loop NOT blocked while worker computes app.get("/compute", async (req, res) => { const result = await runTask({ n: 45 }); res.json({ result }); }); } else { // Worker thread β runs CPU-heavy computation function fib(n) { return n < 2 ? n : fib(n-1) + fib(n-2); } parentPort.postMessage(fib(workerData.n)); }
const cluster = require("cluster"); const http = require("http"); const os = require("os"); if (cluster.isPrimary) { const numCPUs = os.cpus().length; // e.g. 8 console.log(`Primary ${process.pid} forking ${numCPUs} workers`); for (let i = 0; i < numCPUs; i++) cluster.fork(); cluster.on("exit", (worker, code) => { console.log(`Worker ${worker.process.pid} died (code ${code}). Restarting.`); cluster.fork(); // auto-restart crashed workers }); } else { // Each worker is a full Node.js process with its own event loop http.createServer((req, res) => { res.end(`Worker ${process.pid} handled this\n`); }).listen(3000); // OS load-balances incoming connections across workers // PM2 alternative: pm2 start app.js -i max }
cluster creates separate OS processes β each has its own memory, event loop, and V8 instance. They communicate via IPC. Best for scaling an HTTP server across CPU cores. worker_threads creates threads within the same process β they can share memory via SharedArrayBuffer. Best for CPU-intensive tasks within a single request (image processing, ML inference). Key: cluster = horizontal scaling of requests; worker_threads = vertical scaling of a single task.exec runs a shell command and buffers all output β easy but dangerous with user input (shell injection) and bad for large output. spawn runs a command directly without a shell and streams output β safer and memory-efficient. fork is like spawn but specifically for spawning another Node.js file with built-in IPC messaging between parent and child.V8 divides memory into two areas: the stack (primitive values, references) and the heap (objects, closures, buffers). The garbage collector (GC) automatically frees heap objects with no references β but it has to stop-the-world to do it, causing latency spikes.
Memory leaks in Node.js happen when you hold references to objects longer than needed. The GC can't free them even though you're "done" with them. Common culprits: global variables, event listeners not removed, closures holding large data, caches with no eviction policy.
node --max-old-space-size=4096 app.js. Monitor with process.memoryUsage(). Profile with Chrome DevTools or node --inspect.// β Leak 1: Global variable accumulation const cache = {}; app.get("/data", (req, res) => { cache[req.query.id] = fetchExpensiveData(); // NEVER evicted! }); // β Leak 2: Event listener not removed function processRequest(req) { process.on("message", handleMessage); // adds listener every call! // Fixed: process.once() or remove listener when done } // β Leak 3: Closure holding large data function makeAdder() { const hugeArray = new Array(1_000_000).fill("data"); return (x) => x + hugeArray.length; // hugeArray can't be GC'd } // β Leak 4: Timers not cleared function startPolling(emitter) { const id = setInterval(() => { emitter.emit("poll"); // holds ref to emitter forever }, 1000); // Must return id and clearInterval when done! }
// Check memory usage programmatically const used = process.memoryUsage(); console.log({ heapUsed: (used.heapUsed / 1024 / 1024).toFixed(2) + " MB", heapTotal: (used.heapTotal / 1024 / 1024).toFixed(2) + " MB", rss: (used.rss / 1024 / 1024).toFixed(2) + " MB", external: (used.external / 1024 / 1024).toFixed(2) + " MB", }); // heapUsed = JS objects in use // heapTotal = total heap allocated // rss = Resident Set Size (entire process memory) // external = C++ objects linked to JS (Buffers) // Monitor periodically β alert if heap grows too fast setInterval(() => { const { heapUsed } = process.memoryUsage(); if (heapUsed > 500 * 1024 * 1024) { logger.warn("High heap usage!", { heapMB: heapUsed/1024/1024 }); } }, 30_000); // Debug with Chrome DevTools: // $ node --inspect app.js // Open chrome://inspect β heap snapshots β compare over time
// β Fix 1: Bounded cache with LRU eviction const LRU = require("lru-cache"); const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 }); // 500 items, 5min TTL // β Fix 2: Remove event listeners const handler = () => { /* ... */ }; emitter.on("event", handler); // later... emitter.off("event", handler); // or: emitter.removeListener // Or use once() for fire-once handlers emitter.once("event", handler); // auto-removed after first call // β Fix 3: WeakMap for private data (GC-friendly) const privateData = new WeakMap(); class MyClass { constructor() { privateData.set(this, { secret: "hidden" }); } getSecret() { return privateData.get(this).secret; } // When `this` is GC'd, WeakMap entry is automatically removed }
process.memoryUsage().heapUsed over time β if it grows steadily without leveling off, you have a leak. (2) Take heap snapshots using --inspect and Chrome DevTools: take snapshot 1, reproduce the leak, take snapshot 2, compare β the objects growing are your culprits. (3) Use tools like clinic.js, heapdump, or memwatch-next for production. Common culprits: unbounded caches, unremoved event listeners, closures holding large data.Buffer for binary data (outside V8 heap), and tune GC with flags like --max-old-space-size.Curated from real interviews at top tech companies. Answers you should be able to say out loud, not just read.
require() and import?require() is CommonJS β synchronous, cached, available everywhere. import is ESM β statically analyzed at parse time, async-capable (top-level await), supports tree-shaking. ESM is the JavaScript standard; CJS is Node.js's historical system.package.json and what is package-lock.json?package.json declares your project's metadata, dependencies (with semver ranges), and scripts. package-lock.json is auto-generated and locks the exact versions of every installed package and its transitive dependencies β ensures reproducible installs across machines. Always commit it.dependencies and devDependencies?dependencies are packages needed at runtime (express, lodash). devDependencies are only needed during development/testing (jest, eslint, nodemon). Running npm install --production skips devDependencies β important for smaller Docker images.pipe() which automatically pauses the Readable when writable.write() returns false, and resumes on the 'drain' event. For manual handling: check write's return value and listen to drain.--inspect, take heap snapshots in Chrome DevTools, compare before/after β growing object types are the source. Tools: clinic.js, heapdump npm package.server.close(), wait for connections to drain, then exit. PM2 sends SIGINT before force-killing β your app has a window to shut down cleanly.
The clearest visual explanation of how Node.js handles thousands of concurrent connections on a single thread. Covers the V8 engine, libuv thread pool, and the magic of non-blocking I/O. Watch this before reading the Event Loop section.
An in-depth walkthrough for worker threads in Node.js. Great companion video for the intermediate section of this guide.
JavaScript programmers like to use words like, βevent-loopβ, βnon-blockingβ, βcallbackβ, βasynchronousβ, βsingle-threadedβ and βconcurrencyβ.We say things like βdonβt block the event loopβ, βmake sure your code runs at 60 frames-per-secondβ, βwell of course, it wonβt work, that function is an asynchronous callback!β