NodeJS Fundamentals
Master Node.js for interviews: the event loop, async patterns, streams, concurrency, and the beginner traps that quietly sink candidates. With worked examples.
Node.js is a runtime that lets you run JavaScript outside the browser, most often on a server. That one sentence hides almost everything that makes Node interesting and almost everything interviewers probe: it is single-threaded but handles thousands of connections at once, it is fast at input/output but easy to accidentally freeze, and it has two module systems, one event loop with surprising ordering rules, and a concurrency model that confuses nearly everyone at first. This guide walks the whole landscape, from "what even is a runtime" to streams, the event loop, worker threads, and the modern 2026 toolset, with realistic examples, the exact phrasing to use in an interview, and a dedicated tour of the beginner mistakes that silently cost people offers. A separate companion guide covers memory, garbage collection, and production failures in depth.
What Node.js Actually Is
Node.js is not a language and not a framework. It is a runtime: a program that bundles Google's V8 JavaScript engine (the same engine inside Chrome) with a set of C and C++ libraries that give JavaScript abilities the browser never had, like reading files, opening network sockets, and talking to the operating system. The most important of those libraries is libuv, which provides the event loop and a thread pool, and is the reason Node can do non-blocking input/output.
Analogy. Think of V8 as a brilliant chef who can only cook (execute JavaScript) and cannot leave the kitchen. Node.js hires that chef and surrounds them with a support staff (libuv and the C++ bindings) who run out to the pantry, answer the phone, and accept deliveries (the file system, network, timers). The chef stays focused on cooking while the staff handle everything that involves waiting.
The defining trait of Node is that it is single-threaded for your JavaScript but non-blocking for input/output. Your code runs on one main thread, so two lines of your JavaScript never truly run at the same instant. But when your code asks for something slow, like a database query or a file read, Node hands that work off and immediately moves on, running other code while it waits. When the slow thing finishes, Node comes back to it.
Blocking vs Non-Blocking
Blocking code does not return until its work is fully finished, so while it runs the single thread is stuck on it and nothing else can happen: no other requests, no timers, no callbacks. Non-blocking code kicks off the work, returns immediately, and lets the thread keep going; the result arrives later through a callback, promise, or event. The clearest way to feel the difference is the same file read done both ways:
const fs = require("node:fs");
// BLOCKING: the "Sync" variants freeze the thread until the work is done.
console.log("before");
const data = fs.readFileSync("big.txt", "utf8"); // thread FROZEN here
console.log("after"); // waits for the whole read
// Order: "before", (long pause), "after"// NON-BLOCKING: the callback/promise variants hand the work off and continue.
console.log("before");
fs.readFile("big.txt", "utf8", (err, data) => {
console.log("file done"); // runs LATER, when the read finishes
});
console.log("after"); // runs immediately, does NOT wait
// Order: "before", "after", (later) "file done"In the blocking version, if a web request triggered that readFileSync, every other user would be frozen for the entire read. In the non-blocking version, the thread is free to serve thousands of other requests while the disk does its work.
Analogy. Blocking is standing at the microwave watching your food heat, unable to do anything else for three minutes. Non-blocking is starting the microwave and walking off to chop vegetables, coming back when it beeps. Same three minutes of cooking, but only one of them wastes your attention the whole time.
This raises the obvious question: if JavaScript is single-threaded, who is reading the file while your code keeps running? The work is handed off to libuv's background thread pool (for file system work) or to the operating system's async facilities (for network work), both off your main thread. When the operation finishes, libuv places your callback in a queue, and the event loop runs it once your thread is free. So "non-blocking" does not mean the work is instant; it means your thread does not sit and wait for it. The blocking readFileSync uses the same underlying machinery but deliberately parks your thread until the result is ready and hands it back inline. Same I/O, but one version waits and one walks away.
Blocking is not inherently wrong; it is wrong in the wrong place. The rule is about where the code runs. It is fine for one-time startup work, before the server starts listening, because the brief freeze affects no users when none are connected yet. It is dangerous in a request handler or any hot path, where one blocking call freezes every concurrent user.
// ✅ Acceptable: runs once at startup, no users waiting yet.
const config = JSON.parse(fs.readFileSync("./config.json", "utf8"));
server.listen(3000);
// ❌ Dangerous: blocks the whole server on every request.
app.get("/report", (req, res) => {
const data = fs.readFileSync("./huge.csv", "utf8"); // freezes all other requests
res.send(process(data));
});Beginner trap. Blocking is not only aboutSyncmethods. Any CPU-bound work with noawaitto yield on also occupies the thread: a giantforloop,JSON.parseon a 50 MB string, a catastrophic regex, or synchronous crypto likecrypto.pbkdf2Sync. These have no I/O to hand off, so making them "async" does not help; the fix is to move the computation to aworker_thread.
Interview answer: "Why is Node.js good for I/O-heavy apps but bad for CPU-heavy ones?" Node runs your JavaScript on a single thread and uses an event loop to juggle many waiting operations at once. For I/O-heavy work (APIs, proxies, chat servers, anything that mostly waits on databases, files, or other services) this is ideal, because while one request waits, the thread serves thousands of others. For CPU-heavy work (image processing, large computations, complex cryptography) it is poor by default, because a long calculation has no "waiting" to yield during, so it blocks the single thread and freezes every other request until it finishes.
Beginner trap. "Single-threaded" does not mean Node can only do one thing at a time. The JavaScript runs on one thread, but libuv has a background thread pool (default size 4) that handles certain operations like file system calls and some crypto in parallel. So Node is single-threaded where your code is concerned, but multi-threaded under the hood for specific tasks. Conflating these two is one of the most common stumbles.
The Module System: CommonJS vs ESM
Node has two ways to split code into files, and the friction between them is a guaranteed interview topic.
CommonJS (CJS) is the original Node system: require() to import, module.exports to export. It is synchronous and has been the default for most of Node's life.
ES Modules (ESM) is the standard JavaScript module system: import and export. It is the modern direction, supports top-level await, and matches what runs in browsers.
// CommonJS
const fs = require("node:fs");
module.exports = { greet };
// ES Modules
import fs from "node:fs";
export { greet };How does Node decide which one a file uses? The rules that actually matter:
- A file ending in
.cjsis always CommonJS. A file ending in.mjsis always ESM. - A plain
.jsfile is treated as ESM only if the nearestpackage.jsonhas"type": "module". Otherwise it is CommonJS.
Analogy. CJS and ESM are two languages that mostly translate, but with a few words that do not. Node is a translator who is usually fluent both ways, but a handful of phrases (the ones below) come out wrong, and those are exactly what interviewers ask about.
Beginner trap.__dirnameand__filenamedo not exist in ESM. They are CommonJS-only globals. In an ESM file you reconstruct the directory fromimport.meta.url:
// ESM equivalent of __dirname
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);Interview answer: "Can you require() an ESM module, or import a CommonJS one?" You can import a CommonJS module from ESM; Node exposes its module.exports as the default export. Historically you could not require() an ESM module, because require is synchronous and ESM can be asynchronous (top-level await), so the old answer was "use dynamic import() instead." Recent Node versions added support for require()-ing ESM that has no top-level await, which softens the rule, but the safe, always-correct cross-boundary tool is the dynamic import() function, which returns a promise and works from anywhere.
Beginner trap. Mixing them up in one project causes the dreadedCannot use import statement outside a moduleorrequire is not defined in ES module scopeerrors. The fix is almost always checking yourpackage.json"type"field and your file extensions, not changing your code.
The Event Loop: The Heart of Node
The event loop is the mechanism that lets a single thread handle many operations without blocking. It is also the single most misunderstood and most-tested part of Node. Understand it deeply and a whole category of "what prints first" puzzles becomes trivial. This section goes slowly because the details are exactly what interviewers probe.
The Six Phases, In Order
libuv runs the loop as a fixed sequence of phases. Each phase owns a queue of callbacks, drains that queue, then hands off to the next phase. After the last phase it returns to the first, which is one full "tick" of the loop. The phases, in the order they execute:
- Timers: runs callbacks whose
setTimeoutorsetIntervaldelay has elapsed. - Pending callbacks: runs a few system-level callbacks deferred from the previous iteration (for example, certain TCP error callbacks). You rarely interact with this directly.
- Idle / prepare: internal bookkeeping only. Ignore it for interviews.
- Poll: the heart of the loop. It retrieves new I/O events and runs most I/O callbacks (completed file reads, incoming network data). If there is nothing else to do, the loop can block and wait here for I/O.
- Check: runs
setImmediatecallbacks. - Close callbacks: runs close-event callbacks like
socket.on('close', ...).
┌───────────────────────────┐
┌─>│ Timers │ setTimeout / setInterval
│ ├───────────────────────────┤
│ │ Pending callbacks │ some deferred system callbacks
│ ├───────────────────────────┤
│ │ Idle / prepare │ internal only
│ ├───────────────────────────┤ ┌──────────────────┐
│ │ Poll │<─────│ incoming I/O │ most I/O callbacks
│ ├───────────────────────────┤ └──────────────────┘
│ │ Check │ setImmediate
│ ├───────────────────────────┤
└──│ Close callbacks │ socket 'close', etc.
└───────────────────────────┘
(after EVERY callback above, the microtask queues are drained: nextTick first, then Promises)Analogy. The event loop is a security guard doing rounds of a building, visiting the same stations in the same order, over and over. At each station (phase) they handle everyone waiting in that station's line, then move to the next.setTimeoutpeople wait at the Timers station;setImmediatepeople wait at the Check station; finished file reads wait at the Poll station. The guard never skips ahead. The crucial extra rule: after helping each individual person, the guard checks a VIP list (the microtask queues) and clears it completely before calling the next person.
The Priority Hierarchy
This is the ranking to memorize. From highest priority (runs soonest) to lowest:
- Synchronous code. The current call stack always runs to completion first. Nothing asynchronous interrupts it.
process.nextTickqueue. Drained completely, including any newnextTickcallbacks added while draining, before anything else.- Promise microtask queue.
.then,.catch,.finally, andawaitcontinuations. Drained completely after thenextTickqueue. - Macrotask phases, in the loop order above (Timers, Poll, Check, ...).
The detail that makes the hard puzzles solvable: the microtask queues (steps 2 and 3) are drained after every single macrotask callback, not just once per loop. So if two timers are queued, Node runs the first timer callback, drains all microtasks, then runs the second timer callback, then drains microtasks again.
Interview one-liner. "Sync runs first. ThennextTick. Then Promises. Then the loop phases. And microtasks (nextTickthen Promises) drain after each individual callback along the way."
Question 1: The Basics
console.log("1: sync");
setTimeout(() => console.log("2: timeout"), 0);
Promise.resolve().then(() => console.log("3: promise"));
process.nextTick(() => console.log("4: nextTick"));
console.log("5: sync");Output: 1, 5, 4, 3, 2. The two synchronous lines run first (1, 5). Before the loop advances to any phase, microtasks drain: nextTick is highest (4), then the Promise queue (3). Only then does the Timers phase run (2).
Question 2: nextTick vs Promise Priority
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));Output: nextTick, promise. Even though the promise was scheduled first in the source, the entire nextTick queue is drained before the Promise queue is touched. Source order does not matter across the two queues; the queue's priority does.
Question 3: nextTick Fully Drains, Even Newly Added Ones
process.nextTick(() => console.log("tick 1"));
Promise.resolve().then(() => console.log("promise 1"));
process.nextTick(() => {
console.log("tick 2");
process.nextTick(() => console.log("tick 3")); // added DURING draining
});Output: tick 1, tick 2, tick 3, promise 1. The nextTick queue drains to completion. While running tick 2, a new tick 3 is added to that same queue, and because the queue drains until empty, tick 3 runs before Node ever moves on to promises. This is also exactly how nextTick recursion can starve the loop.
Question 4: Microtasks Drain Between Timers (Node 11+)
setTimeout(() => {
console.log("timeout 1");
Promise.resolve().then(() => console.log("promise 1"));
}, 0);
setTimeout(() => {
console.log("timeout 2");
Promise.resolve().then(() => console.log("promise 2"));
}, 0);Output: timeout 1, promise 1, timeout 2, promise 2. Both timer callbacks sit in the Timers phase. Node runs timeout 1, which queues promise 1; before running the next timer, it drains microtasks, so promise 1 prints. Then timeout 2 runs, queues promise 2, and microtasks drain again. (Trick value: in Node 10 and earlier the output was timeout 1, timeout 2, promise 1, promise 2, because microtasks only drained once per phase. This is a favorite "which Node version" gotcha.)
Question 5: setTimeout vs setImmediate
// At the TOP LEVEL, the order is NOT guaranteed:
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// Prints "timeout" then "immediate", OR the reverse, depending on timing.
// INSIDE an I/O callback, setImmediate ALWAYS wins:
const fs = require("node:fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// Always prints: immediate, then timeout
});At the top level the result is non-deterministic because it depends on whether the 0 ms timer is "ready" by the time the loop reaches the Timers phase on the very first tick. Inside an I/O callback the answer is fixed: I/O callbacks run in the Poll phase, and the very next phase is Check (where setImmediate lives), while Timers is at the top of the following tick. So setImmediate is always reached first. Being able to explain why is the actual test.
Question 6: async/await Is Just Promises
await is syntax sugar. The line after an await becomes a Promise microtask, exactly like a .then callback. Tracing this is what unlocks the hardest questions.
async function a() {
console.log("a: start");
await b(); // everything after this await is a microtask
console.log("a: end");
}
async function b() {
console.log("b");
}
console.log("script: start");
a();
console.log("script: end");Output: script: start, a: start, b, script: end, a: end. Calling a() runs synchronously up to the await: it prints a: start, then calls b() which prints b. The await then suspends a and schedules the rest (a: end) as a microtask, returning control to the top level, which prints script: end. After the synchronous code finishes, the microtask runs: a: end.
Question 7: The Boss Level
Combine everything. Trace it before reading the answer.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
process.nextTick(() => console.log("4"));
(async () => {
console.log("5");
await null; // suspends here; "6" becomes a microtask
console.log("6");
})();
console.log("7");Output: 1, 5, 7, 4, 3, 6, 2. Walk it:
- Synchronous pass:
1prints. The timer, promise, andnextTickare scheduled. The async IIFE runs up to itsawait, printing5, then suspends and queues6as a Promise microtask. Back at the top level,7prints. Synchronous output so far:1, 5, 7. - Microtask drain:
nextTickqueue first, so4. Then the Promise queue in the order things were queued:3was queued before6, so3, then6. - Macrotask: the Timers phase runs
2.
Beginner trap: starving the loop. Because thenextTickqueue drains fully (including newly added callbacks) before the loop can advance, recursively schedulingnextTickcallbacks starves the event loop completely, so timers and I/O never get a turn and the app hangs while pinning the CPU. The same recursion withsetImmediateis safe, becausesetImmediateschedules into the Check phase and yields back to the loop between iterations. PrefersetImmediatefor "do this soon, but let other work happen in between."
Interview answer: "What is the difference between process.nextTick, Promise.then, setImmediate, and setTimeout?" They run at four different priorities. process.nextTick is highest: its queue drains immediately after the current operation, before promises and before the loop advances. Promise callbacks run next, in the microtask queue, after nextTick. Then the loop's phases run: setTimeout callbacks fire in the Timers phase, and setImmediate callbacks fire in the Check phase. Microtasks (nextTick then Promises) are flushed after every individual callback, which is why a promise queued inside a timer callback runs before the next timer callback.
process.nextTick: What It Is Actually For
process.nextTick(fn) schedules fn to run the instant the current operation finishes, before the event loop does anything else. It is the highest-priority way to defer work: it does not wait for a timer or for I/O, it jumps the entire queue. That power makes people assume it is exotic, but it exists for a few specific, legitimate jobs.
1. Emit an event after the caller has had a chance to attach listeners. If you emit an event synchronously inside a constructor, the user has not yet called .on(), so they miss it entirely. Deferring the emit by one tick lets the synchronous code, including the listener registration, finish first.
const EventEmitter = require("node:events");
class Job extends EventEmitter {
constructor() {
super();
// ❌ Emitting synchronously: nobody is listening yet, the event is lost.
// this.emit("ready");
// ✅ Defer to next tick so the caller's .on("ready") is registered first.
process.nextTick(() => this.emit("ready"));
}
}
const job = new Job();
job.on("ready", () => console.log("got it")); // now fires correctlyWithout the nextTick, emit("ready") runs during new Job(), which is before the very next line where .on("ready") is attached, so the listener never hears it. Deferring one tick lets the synchronous code finish first.
2. Make an inconsistent API consistently asynchronous. A function that is sometimes synchronous (a cache hit) and sometimes asynchronous (a cache miss) is a bug magnet, because the caller cannot predict whether their callback fires before or after the calling line returns. Routing the synchronous path through nextTick makes the function always async, so its behavior is predictable.
function getUser(id, callback) {
if (cache.has(id)) {
// Don't call back synchronously; that makes this function unpredictable.
return process.nextTick(() => callback(null, cache.get(id)));
}
db.query(id, callback); // the miss path is already async
}This is the principle sometimes called "don't release Zalgo": a callback that fires synchronously in some cases and asynchronously in others creates ordering bugs that are very hard to reason about. Forcing it to always fire after the current stack unwinds gives the API one consistent behavior.
3. Run cleanup or error reporting right after the current stack unwinds, before any timers or I/O get a turn, when you want that work to happen as soon as possible but not interrupt the operation in progress.
nextTickvssetImmediate. Despite the names,setImmediateis the later of the two.process.nextTickfires before the event loop continues, so it is more urgent;setImmediatefires on the loop's next Check phase, after I/O has had a turn. Practical guidance: reach forsetImmediateby default when you just want to "do this soon but yield to the rest of the app first," and reservenextTickfor the specific cases above, because its jump-the-queue power is also its hazard.
Beginner trap: loop starvation. Because the entirenextTickqueue drains, including newnextTickcalls added while draining, before the loop can advance, a recursivenextTickstarves the loop completely: timers and I/O never run and the app hangs at full CPU. The same recursion withsetImmediateis safe, because it yields back to the loop each iteration.
// ❌ Starves the loop forever: timers and I/O never get a turn.
function loop() { process.nextTick(loop); }
// ✅ Safe: yields to the event loop between iterations.
function loop() { setImmediate(loop); }Asynchronous Node: Callbacks, Promises, async/await
Node was built on callbacks, evolved to promises, and now mostly uses async/await. Knowing all three and how they relate is essential, because real codebases contain all of them.
Callbacks are functions you pass in to be called when work finishes. Node's convention is the error-first callback: the first argument is an error (or null), the rest is the result.
fs.readFile("file.txt", "utf8", (err, data) => {
if (err) return console.error(err); // always check the error first
console.log(data);
});The problem with callbacks is nesting. Sequential async steps pyramid to the right, producing the infamous "callback hell":
getUser(id, (err, user) => {
getOrders(user, (err, orders) => {
getDetails(orders, (err, details) => {
// three levels deep and growing
});
});
});Promises flatten this. A promise is an object representing a value that will exist later, with .then for success and .catch for failure.
async/await is syntax sugar over promises that lets asynchronous code read like synchronous code:
async function loadDashboard(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user);
const details = await getDetails(orders);
return details;
} catch (err) {
console.error("Failed to load:", err); // one place catches all three
}
}Analogy. A callback is leaving your phone number and waiting for a call back. A promise is a claim ticket at a coat check: you hold it now, and it resolves to your coat later.async/awaitis standing politely at the counter saying "I will wait here for my coat before doing the next thing," which reads naturally even though you are technically still waiting asynchronously.
Beginner trap: forgettingawait. Calling an async function withoutawaitdoes not run it synchronously; it returns a pending promise that you have ignored. The code after it runs immediately, often before the async work finishes, and any error inside becomes an unhandled rejection.
// ❌ The save may not have happened yet, and errors vanish silently.
function handler() {
saveToDatabase(data); // returns a promise nobody waited for
res.send("Saved!"); // lies: the save might still be in flight or failing
}
// ✅ Await it (and the function must be async to use await).
async function handler() {
await saveToDatabase(data);
res.send("Saved!");
}Beginner trap:awaitin a loop when you wanted parallelism. Awaiting inside aforloop runs requests one after another, which is slow when they are independent. UsePromise.allto run them concurrently.
// ❌ Sequential: waits for each before starting the next (slow)
const results = [];
for (const id of ids) {
results.push(await fetchUser(id));
}
// ✅ Concurrent: starts them all, waits for the batch (fast)
const results = await Promise.all(ids.map(id => fetchUser(id)));Interview answer: "What does Promise.all do, and how is it different from Promise.allSettled?" Promise.all takes an array of promises and resolves to an array of their results once all succeed, but it rejects immediately if any single one rejects, discarding the others' results. Promise.allSettled waits for every promise to finish regardless of outcome and resolves to an array describing each one as either fulfilled with a value or rejected with a reason. Use all when you need every piece and any failure should abort; use allSettled when you want all the outcomes even if some fail, like sending many notifications where one failure should not cancel the rest.
Beginner trap:forEachwith async does not wait.array.forEach(async ...)fires all the async callbacks but does not await them, so code after theforEachruns before any of them finish. Use afor...ofloop withawaitfor sequential, orPromise.all(array.map(...))for parallel.forEachsimply ignores returned promises.
The Concurrency Model: One Thread, Many Tricks
People hear "single-threaded" and assume Node cannot use multiple CPU cores. It can; it just does not do so for your JavaScript automatically. Here is the full picture.
Your JavaScript runs on one main thread. But Node has several escape hatches for parallelism:
- The libuv thread pool (default 4 threads) handles certain built-in operations like file system access and some crypto in the background, automatically. You do not manage these threads; Node does.
worker_threadslet you run JavaScript on actual separate threads, each with its own event loop and memory. This is the right tool for CPU-bound work like parsing huge files or heavy computation.clusterforks multiple copies of your entire Node process, each on its own core, sharing a server port, so a web server can use all cores. Each worker is a full separate process.child_processspawns external programs or other Node scripts as separate OS processes.
Analogy. The main thread is a single skilled receptionist who can handle a huge number of phone calls by never staying on hold (non-blocking I/O). But if someone asks the receptionist to personally solve a long math problem (CPU-bound work), every caller waits.worker_threadsis hiring a second person in a back room to do the math while the receptionist keeps answering calls.clusteris opening several identical reception desks so more callers can be served at once.
// Offloading CPU-heavy work to a worker thread so the main thread stays free
const { Worker } = require("node:worker_threads");
function runHeavyTask(data) {
return new Promise((resolve, reject) => {
const worker = new Worker("./heavy-task.js", { workerData: data });
worker.on("message", resolve);
worker.on("error", reject);
});
}Interview answer: "When would you use worker_threads versus cluster?" Use worker_threads for CPU-bound work inside a single application: parsing, image manipulation, encryption, anything that would otherwise block the event loop. Workers share memory efficiently via SharedArrayBuffer and are lighter than processes. Use cluster to scale an I/O-bound server across CPU cores by running multiple instances of the whole app behind one port, so each core handles a share of incoming connections. In short: worker_threads for parallel computation within an app, cluster for serving more traffic by replicating the app.
Beginner trap. Reaching for worker_threads for ordinary I/O is usually a mistake. If your slow operation is a database query or an HTTP call, it already does not block the event loop; spinning up a worker adds overhead for no benefit. Workers are for CPU work, not for making already-async I/O "more parallel."Streams and Buffers: Handling Data Piece by Piece
A stream processes data in chunks as it arrives, instead of loading everything into memory at once. This is how Node handles large files, network responses, and any data too big or too continuous to fit comfortably in memory.
There are four types: Readable (you read from it, like a file being read), Writable (you write to it, like a file being written), Duplex (both, like a network socket), and Transform (a duplex stream that modifies data as it passes through, like compression).
Analogy. Reading a whole file into memory is filling an enormous bathtub before you can use any water. A stream is a faucet: water flows through continuously, you use it as it comes, and you never need a tub big enough to hold all of it at once. For a 10 GB file, the tub approach runs you out of memory; the faucet just keeps flowing.
// ❌ Loads the entire file into memory: fine for small files, fatal for huge ones
const data = fs.readFileSync("huge.log");
res.end(data);
// ✅ Streams the file straight to the response, a chunk at a time
const stream = fs.createReadStream("huge.log");
stream.pipe(res); // memory stays flat regardless of file sizeThe crucial concept attached to streams is backpressure: when you read data faster than the destination can write it, the data piles up in memory. pipe (and the safer pipeline) handle this automatically by pausing the source when the destination is overwhelmed and resuming when it catches up.
const { pipeline } = require("node:stream/promises");
// pipeline handles backpressure AND cleans up/propagates errors properly
await pipeline(
fs.createReadStream("input.txt"),
zlib.createGzip(),
fs.createWriteStream("input.txt.gz")
);Interview answer: "What problem do streams solve, and what is backpressure?" Streams let you process data incrementally instead of buffering it all in memory, which keeps memory usage flat and constant even for arbitrarily large data, and lets you start working on the first bytes before the last ones arrive. Backpressure is the situation where a fast source produces data quicker than a slow destination can consume it; without handling it, the excess accumulates in memory and can crash the process. Node's pipe and pipeline manage backpressure automatically by pausing and resuming the source to match the destination's pace.
Beginner trap. A Buffer is Node's way of holding raw binary data (bytes), since JavaScript strings are not great for binary. Beginners often turn a Buffer into a string too early (losing binary fidelity) or concatenate Buffers inefficiently in a loop. When collecting stream chunks, push them into an array and Buffer.concat once at the end rather than concatenating on every chunk.Beginner trap: not handling theerrorevent. Streams emit errors as events, not as thrown exceptions. AcreateReadStreamon a missing file does not throw; it emits anerrorevent, and if nothing is listening, it crashes the process. Always attach an error handler, or usepipeline, which propagates errors to its callback or promise for you.
EventEmitter: Node's Pub/Sub Core
Much of Node is built on the EventEmitter pattern: objects that emit named events, and listeners that react to them. Streams, HTTP servers, and sockets are all EventEmitters under the hood.
const EventEmitter = require("node:events");
class Order extends EventEmitter {}
const order = new Order();
order.on("shipped", (id) => console.log(`Order ${id} shipped`)); // listener
order.emit("shipped", 42); // triggers itAnalogy. An EventEmitter is a radio station.emitis broadcasting on a named channel;onis anyone tuning in to that channel. The broadcaster does not know or care who is listening, and listeners do not know about each other. This decoupling is the whole point: the part that knows when something happened is separate from the parts that decide what to do about it.
Beginner trap: the memory leak warning. If you add listeners in a function that runs repeatedly (like per request) without removing them, they pile up. Node warns at 11 listeners on one event withMaxListenersExceededWarning, which is usually a real leak, not something to silence by raising the limit. Add the listener once, or remove it withoff/removeListenerwhen done, or useoncefor one-shot listeners.
Beginner trap: the specialerrorevent. If an EventEmitter emits anerrorevent and there is no listener for it, Node throws and crashes the whole process. This is intentional: an unhandled error event is treated as a fatal unhandled exception. Always listen forerroron emitters that can fail.
Error Handling Done Right
Error handling in Node is genuinely tricky because synchronous and asynchronous errors behave completely differently, and getting it wrong either crashes your server or hides bugs.
Synchronous errors are caught by try/catch. Asynchronous errors in callbacks are not; they arrive as the error-first argument. Promise/async errors are caught by .catch or by try/catch around await.
// ❌ try/catch does NOT catch errors from an async callback
try {
fs.readFile("missing.txt", (err, data) => {
if (err) throw err; // this throw escapes the try/catch entirely
});
} catch (e) {
// never runs: the callback fires later, outside this try block
}
// ✅ Handle the error where it arrives
fs.readFile("missing.txt", (err, data) => {
if (err) return handleError(err);
});At the process level, two safety nets catch what you missed:
// A synchronous error nobody caught
process.on("uncaughtException", (err) => {
console.error("Uncaught:", err);
process.exit(1); // best practice: log, then exit and let a supervisor restart
});
// A rejected promise nobody caught
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exit(1);
});Interview answer: "Should you keep the process running after an uncaughtException?" No, you should not resume normal operation. An uncaught exception means your app is in an unknown, possibly corrupted state, so the safe practice is to log the error, perform a fast cleanup if needed, and exit the process, letting a process manager (like a container orchestrator, PM2, or systemd) start a fresh instance. Treating uncaughtException as a "catch everything and continue" handler is dangerous because you may continue with leaked resources or inconsistent state. The goal is a clean crash and restart, not survival.
Beginner trap. Swallowing errors with an emptycatch {}or a.catch(() => {})is one of the most common and damaging habits. It makes failures invisible, so problems surface later as mysterious wrong behavior instead of a clear error. Always at least log, and rethrow if you cannot meaningfully handle it.
Graceful shutdown. Production servers should listen for termination signals and close connections cleanly so in-flight requests finish:
process.on("SIGTERM", async () => {
console.log("Shutting down...");
await server.close(); // stop accepting new connections, finish current ones
await db.disconnect();
process.exit(0);
});Building an HTTP Server
At its core, Node can serve HTTP with no framework at all. The request is a Readable stream; the response is a Writable stream.
const http = require("node:http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ message: "Hello" }));
});
server.listen(3000, () => console.log("Listening on 3000"));In practice most teams use a framework like Express (or Fastify), whose central idea is middleware: a chain of functions that each receive the request, response, and a next function, and can inspect, modify, or end the request.
const express = require("express");
const app = express();
// Middleware runs in order; each calls next() to pass control along.
app.use(express.json()); // parse JSON bodies
app.use((req, res, next) => { // simple logger
console.log(`{req.method}{req.url}`);
next(); // forgetting this hangs the request
});
app.get("/users/:id", (req, res) => {
res.json({ id: req.params.id });
});
app.listen(3000);Analogy. Middleware is an assembly line. The request moves down the belt, and each station (middleware) does one job: unpack the body, check the auth badge, log the visit, then hand it to the next station.next()is the worker pushing the item along. If a worker forgets to push (next()is never called), the item sits there forever and the request hangs.
Beginner trap: forgettingnext()or sending two responses. If middleware neither ends the response nor callsnext(), the request hangs until it times out. Conversely, callingres.send()(orres.json()) twice, or afternext()already led somewhere that responded, throwsCannot set headers after they are sent. Each request must get exactly one response, exactly once.
Beginner trap: async errors in Express 4. In Express 4, throwing inside an async route handler does not reach your error middleware automatically; you must catch and pass it with next(err), or wrap handlers. Express 5 improved this by forwarding rejected promises automatically. Know which version you are on.The File System and Paths
The fs module comes in three flavors, and choosing the wrong one is a frequent beginner error.
const fs = require("node:fs");
const fsp = require("node:fs/promises");
// 1) Synchronous: blocks the event loop until done. Fine at startup, dangerous in a request handler.
const data = fs.readFileSync("config.json", "utf8");
// 2) Callback: classic async, error-first.
fs.readFile("config.json", "utf8", (err, data) => { /* ... */ });
// 3) Promise-based: the modern default for application code.
const data2 = await fsp.readFile("config.json", "utf8");Beginner trap:Syncmethods in request handlers.readFileSync,writeFileSync, and friends block the entire event loop while they run, freezing every other request. They are acceptable during one-time startup (loading config before the server listens), but using them inside a route handler means one slow disk read stalls your whole server. Default to the promise-basedfs/promisesAPI in anything that runs per request.
Beginner trap: building paths with string concatenation. Joining paths with+and/breaks across operating systems and mishandles edge cases. Usepath.join(and resolve user-relative paths against__dirnameorimport.meta.url), and never trust user input in a file path without sanitizing it, or you open a path-traversal vulnerability.
const path = require("node:path");
const safe = path.join(__dirname, "uploads", filename); // correct, cross-platformSecurity Essentials
Security questions come up even in non-security roles. The high-value points:
- Never store secrets in code. Keep API keys and passwords in environment variables (or a secret manager), not in the repository.
- Validate and sanitize all input. Treat anything from a user as hostile: validate request bodies, escape data in queries (use parameterized queries to prevent SQL injection), and sanitize anything that ends up in HTML or a shell command.
- Audit dependencies. A huge share of Node vulnerabilities come from packages, not your code. Run
npm audit, keep dependencies updated, and minimize how many you pull in. - The Permission Model. Modern Node (the flag is now
--permission, having shed its experimental prefix) can restrict what a process may touch: file system reads and writes, child process spawning, and worker threads, enforced at the runtime level. It is useful for running untrusted scripts with least privilege.
# Run with no file-system write access and no child-process spawning
node --permission --allow-fs-read=./data app.jsBeginner trap: trustingprocess.envto exist. Referencingprocess.env.DATABASE_URLwhen it is undefined gives youundefined, not an error, so the failure shows up far away as a confusing connection bug. Validate required environment variables at startup and fail fast with a clear message if any are missing.
Modern Node.js (2026)
Node has been on a "batteries included" push, absorbing things that used to require third-party packages. Worth knowing because interviewers increasingly ask "how would you do this without a library?"
- Native
fetch. The browser'sfetchAPI is built in, so you no longer needaxiosornode-fetchfor simple HTTP requests. It is backed by the Undici client. - Built-in test runner. The
node:testmodule plusnode:assertgives you a full test framework with no Jest or Mocha needed, and recent versions auto-await subtests so tests are less error-prone. - Built-in watch mode.
node --watch app.jsrestarts on file changes, replacingnodemonfor most development. - Native TypeScript. Recent Node can run
.tsfiles directly by stripping the type annotations, removing the build step for many scripts. - Built-in SQLite. The
node:sqlitemodule provides a local database with no native addon to compile. AsyncLocalStorage. Lets you carry context (like a request ID) across asynchronous calls without threading it through every function argument, which is the idiomatic way to do per-request logging context.
// Native fetch and the built-in test runner, zero dependencies
import { test } from "node:test";
import assert from "node:assert/strict";
test("fetches a user", async () => {
const res = await fetch("https://api.example.com/users/1");
const user = await res.json();
assert.equal(user.id, 1);
});node --test # runs the built-in test runner
node --watch app.js # auto-restart on changes, no nodemonInterview answer: "How do you make an HTTP request in modern Node without a library?" Use the built-infetch, which is globally available and standards-based:const res = await fetch(url); const data = await res.json();. For streaming, large uploads, or fine-grained control, thenode:http/node:httpsmodules are still there, and the underlying Undici client is available directly for advanced cases like connection pooling. The point is that for ordinary requests, no dependency is needed anymore.
The Beginner Mistakes That Quietly Fail Interviews
A consolidated list of the misunderstandings that most often reveal a shallow mental model. If you can explain why each is wrong, you are ahead of most candidates.
"Node is single-threaded, so it can't use multiple cores." Your JavaScript runs on one thread, but libuv uses a background thread pool, andworker_threadsandclustergive you real multi-core parallelism. The single thread is about your code, not the whole runtime.
"setTimeout(fn, 0)runsfnimmediately." It schedulesfnfor the next Timers phase, after all current synchronous code and all microtasks (nextTickand promises). It runs soon, not now, and several things run before it.
"awaitmakes my code synchronous." It pauses the current async function until the promise settles, but the rest of the program keeps running. The thread is never blocked;awaitjust sequences your function's own steps.
"I'll loop withawaitto process an array." That runs items one at a time. If they are independent, you wantedPromise.allfor concurrency. Sequentialawaitin a loop is a common, quiet performance killer.
"forEachwith an async callback waits for each." It does not.forEachignores returned promises, so everything after it runs before the async work finishes. Usefor...ofwithawaitorPromise.all(map(...)).
"I'll read the whole file then send it." Fine for small files, but readFileSync blocks the loop and reading a huge file into memory can crash the process. Stream large files and prefer async file APIs in request handlers."try/catchwill catch this async error." Only forawaited promises and synchronous code. Errors in bare callbacks arrive as the error argument, and an error thrown inside a callback escapes the surroundingtry/catchentirely.
"An empty catch is fine, the error wasn't important." Swallowed errors become invisible bugs that surface later as baffling behavior. Always log; rethrow if you cannot handle it."I'll keep the server running after uncaughtException." The process may be in a corrupted state. Log, then exit and let a supervisor restart it. Surviving an unknown error is riskier than restarting."Morenpm installis progress." Every dependency is attack surface, bundle weight, and a maintenance liability. Modern Node has nativefetch, a test runner, watch mode, and SQLite built in. Reach for the standard library first.
Rapid-Fire Q&A
Is Node.js a programming language? No. It is a runtime environment that executes JavaScript outside the browser, built on the V8 engine plus libuv and system bindings.
What ispackage.json? The manifest of a Node project: its name, version, scripts, and dependencies.package-lock.jsonrecords the exact resolved versions so installs are reproducible.
What is the difference between dependencies and devDependencies?dependenciesare needed to run the app in production;devDependenciesare needed only during development and testing (test runners, bundlers, linters) and are skipped in production installs.
What does the libuv thread pool do? It runs certain operations (file system calls, DNS lookups, some crypto) on background threads so they do not block the main thread. Its default size is 4 and can be tuned with the UV_THREADPOOL_SIZE environment variable.What is the difference betweenprocess.nextTickandsetImmediate?nextTickcallbacks run before the event loop continues, ahead of promises and all phases.setImmediatecallbacks run in the Check phase of the loop.nextTickis higher priority and can starve the loop if abused;setImmediateyields between iterations.
What is middleware? A function in a request-handling chain that receives the request, response, andnext, and can read or modify them, end the response, or pass control onward by callingnext().
What is the difference betweenspawnandexecinchild_process?spawnstreams output and is suited to long-running processes or large output;execbuffers the entire output into memory and is convenient for short commands but dangerous for large output.
How do you avoid blocking the event loop? Keep CPU-heavy work off the main thread (useworker_threads), prefer async I/O overSyncmethods, stream large data, and break large computations into chunks that yield. The event loop must stay free to keep the app responsive.
Common Coding Challenges
Promisify a callback-based function:
function promisify(fn) {
return (...args) =>
new Promise((resolve, reject) => {
fn(...args, (err, result) => (err ? reject(err) : resolve(result)));
});
}
const readFileAsync = promisify(fs.readFile);Retry with exponential backoff:
async function retry(fn, attempts = 3, delay = 200) {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1) throw err; // out of attempts
await new Promise(r => setTimeout(r, delay * 2 ** i)); // wait, growing
}
}
}Run async tasks with a concurrency limit (a very common ask):
async function mapLimit(items, limit, worker) {
const results = [];
const executing = new Set();
for (const [i, item] of items.entries()) {
const p = Promise.resolve(worker(item)).then(r => (results[i] = r));
executing.add(p);
p.finally(() => executing.delete(p));
if (executing.size >= limit) await Promise.race(executing); // wait for a slot
}
await Promise.all(executing);
return results;
}A minimal EventEmitter from scratch:
class Emitter {
#events = new Map();
on(name, fn) {
if (!this.#events.has(name)) this.#events.set(name, []);
this.#events.get(name).push(fn);
return this;
}
emit(name, ...args) {
(this.#events.get(name) || []).forEach(fn => fn(...args));
return this;
}
off(name, fn) {
const fns = this.#events.get(name);
if (fns) this.#events.set(name, fns.filter(f => f !== fn));
return this;
}
}A debounce utility:
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}Tricky Gotchas Interviewers Love
The output-order puzzle. Givenconsole.log,setTimeout(.., 0),Promise.resolve().then, andprocess.nextTickinterleaved, the order is: all synchronous logs first, then allnextTickcallbacks, then all promise callbacks, then the timeout. Practice tracing these out loud.
setTimeoutvssetImmediateat the top level is non-deterministic, but inside an I/O callbacksetImmediatealways runs first. Knowing why (Poll phase is immediately followed by Check) is the real test.
thisinside callbacks. A regular function passed as a callback loses the surroundingthis. Arrow functions inherit it. Mixing these up causesCannot read property of undefinederrors in class methods used as callbacks.
Floating-point andBuffersizing.0.1 + 0.2 !== 0.3in JavaScript (and therefore Node), and allocatingBuffersizes from untrusted numbers can be a denial-of-service vector. Validate sizes.
Module caching.requirecaches a module after first load, so a module's top-level code runs only once and everyrequireof it gets the same instance. People are surprised when shared mutable state in a module persists across imports. This is by design.
The__dirnameESM gap. It does not exist in ES modules. Reconstruct it fromimport.meta.url. This trips up nearly everyone migrating CommonJS to ESM.
Blocking the loop with a regex. A poorly written regular expression can backtrack catastrophically on certain inputs and freeze the entire server (a "ReDoS"). The event loop has no way to interrupt it. Treat user-facing regexes with suspicion.
Unhandlederrorevents crash the process. An EventEmitter (including streams) that emitserrorwith no listener throws fatally. Always handleerrorevents.
Related
Node.js Memory, Garbage Collection & Production Failures
How Node.js memory really works: the V8 heap, garbage collection, memory leaks, OOM crashes, and diagnosing it all in production on EC2 and ECS.
React Rendering Strategies, Explained: CSR, SSR, SSG, ISR, and PPR
CSR, SSR, SSG, ISR, and PPR explained with realistic examples, plus exactly which Core Web Vitals each rendering strategy moves. From build time to the browser.
React Fundamentals
A practical, example-driven React guide from fundamentals to React 19. Master hooks, the Virtual DOM, performance, and the gotchas interviewers actually test.