Half Life
Web Development

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.

By Naga Sai Rao39 min read

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.

ArticleNode.js Memory, Garbage Collection & Production FailuresHow Node.js memory really works: the V8 heap, garbage collection, memory leaks, OOM crashes, and diagnosing it all in production on EC2 and ECS.

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:

js
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"
js
// 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.

js
// ✅ 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 about Sync methods. Any CPU-bound work with no await to yield on also occupies the thread: a giant for loop, JSON.parse on a 50 MB string, a catastrophic regex, or synchronous crypto like crypto.pbkdf2Sync. These have no I/O to hand off, so making them "async" does not help; the fix is to move the computation to a worker_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.

js
// 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 .cjs is always CommonJS. A file ending in .mjs is always ESM.
  • A plain .js file is treated as ESM only if the nearest package.json has "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. __dirname and __filename do not exist in ESM. They are CommonJS-only globals. In an ESM file you reconstruct the directory from import.meta.url:
js
// 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 dreaded Cannot use import statement outside a module or require is not defined in ES module scope errors. The fix is almost always checking your package.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:

  1. Timers: runs callbacks whose setTimeout or setInterval delay has elapsed.
  2. 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.
  3. Idle / prepare: internal bookkeeping only. Ignore it for interviews.
  4. 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.
  5. Check: runs setImmediate callbacks.
  6. Close callbacks: runs close-event callbacks like socket.on('close', ...).
text
   ┌───────────────────────────┐
┌─>│           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. setTimeout people wait at the Timers station; setImmediate people 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:

  1. Synchronous code. The current call stack always runs to completion first. Nothing asynchronous interrupts it.
  2. process.nextTick queue. Drained completely, including any new nextTick callbacks added while draining, before anything else.
  3. Promise microtask queue. .then, .catch, .finally, and await continuations. Drained completely after the nextTick queue.
  4. 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. Then nextTick. Then Promises. Then the loop phases. And microtasks (nextTick then Promises) drain after each individual callback along the way."

Question 1: The Basics

js
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

js
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

js
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+)

js
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

js
// 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.

js
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.

js
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: 1 prints. The timer, promise, and nextTick are scheduled. The async IIFE runs up to its await, printing 5, then suspends and queues 6 as a Promise microtask. Back at the top level, 7 prints. Synchronous output so far: 1, 5, 7.
  • Microtask drain: nextTick queue first, so 4. Then the Promise queue in the order things were queued: 3 was queued before 6, so 3, then 6.
  • Macrotask: the Timers phase runs 2.
Beginner trap: starving the loop. Because the nextTick queue drains fully (including newly added callbacks) before the loop can advance, recursively scheduling nextTick callbacks 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 with setImmediate is safe, because setImmediate schedules into the Check phase and yields back to the loop between iterations. Prefer setImmediate for "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.

js
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 correctly

Without 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.

js
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.

nextTick vs setImmediate. Despite the names, setImmediate is the later of the two. process.nextTick fires before the event loop continues, so it is more urgent; setImmediate fires on the loop's next Check phase, after I/O has had a turn. Practical guidance: reach for setImmediate by default when you just want to "do this soon but yield to the rest of the app first," and reserve nextTick for the specific cases above, because its jump-the-queue power is also its hazard.
Beginner trap: loop starvation. Because the entire nextTick queue drains, including new nextTick calls added while draining, before the loop can advance, a recursive nextTick starves the loop completely: timers and I/O never run and the app hangs at full CPU. The same recursion with setImmediate is safe, because it yields back to the loop each iteration.
js
// ❌ 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.

js
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":

js
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:

js
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/await is 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: forgetting await. Calling an async function without await does 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.
js
// ❌ 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: await in a loop when you wanted parallelism. Awaiting inside a for loop runs requests one after another, which is slow when they are independent. Use Promise.all to run them concurrently.
js
// ❌ 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: forEach with async does not wait. array.forEach(async ...) fires all the async callbacks but does not await them, so code after the forEach runs before any of them finish. Use a for...of loop with await for sequential, or Promise.all(array.map(...)) for parallel. forEach simply 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_threads let 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.
  • cluster forks 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_process spawns 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_threads is hiring a second person in a back room to do the math while the receptionist keeps answering calls. cluster is opening several identical reception desks so more callers can be served at once.
js
// 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.
js
// ❌ 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 size

The 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.

js
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 the error event. Streams emit errors as events, not as thrown exceptions. A createReadStream on a missing file does not throw; it emits an error event, and if nothing is listening, it crashes the process. Always attach an error handler, or use pipeline, 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.

js
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 it
Analogy. An EventEmitter is a radio station. emit is broadcasting on a named channel; on is 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 with MaxListenersExceededWarning, which is usually a real leak, not something to silence by raising the limit. Add the listener once, or remove it with off / removeListener when done, or use once for one-shot listeners.
Beginner trap: the special error event. If an EventEmitter emits an error event 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 for error on 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.

js
// ❌ 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:

js
// 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 empty catch {} 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:
js
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.

js
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.

js
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: forgetting next() or sending two responses. If middleware neither ends the response nor calls next(), the request hangs until it times out. Conversely, calling res.send() (or res.json()) twice, or after next() already led somewhere that responded, throws Cannot 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.

js
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: Sync methods 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-based fs/promises API 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. Use path.join (and resolve user-relative paths against __dirname or import.meta.url), and never trust user input in a file path without sanitizing it, or you open a path-traversal vulnerability.
js
const path = require("node:path");
const safe = path.join(__dirname, "uploads", filename); // correct, cross-platform

Security 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.
bash
# Run with no file-system write access and no child-process spawning
node --permission --allow-fs-read=./data app.js
Beginner trap: trusting process.env to exist. Referencing process.env.DATABASE_URL when it is undefined gives you undefined, 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's fetch API is built in, so you no longer need axios or node-fetch for simple HTTP requests. It is backed by the Undici client.
  • Built-in test runner. The node:test module plus node:assert gives 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.js restarts on file changes, replacing nodemon for most development.
  • Native TypeScript. Recent Node can run .ts files directly by stripping the type annotations, removing the build step for many scripts.
  • Built-in SQLite. The node:sqlite module 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.
js
// 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);
});
bash
node --test          # runs the built-in test runner
node --watch app.js  # auto-restart on changes, no nodemon
Interview answer: "How do you make an HTTP request in modern Node without a library?" Use the built-in fetch, 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, the node:http/node:https modules 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, and worker_threads and cluster give you real multi-core parallelism. The single thread is about your code, not the whole runtime.
"setTimeout(fn, 0) runs fn immediately." It schedules fn for the next Timers phase, after all current synchronous code and all microtasks (nextTick and promises). It runs soon, not now, and several things run before it.
"await makes 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; await just sequences your function's own steps.
"I'll loop with await to process an array." That runs items one at a time. If they are independent, you wanted Promise.all for concurrency. Sequential await in a loop is a common, quiet performance killer.
"forEach with an async callback waits for each." It does not. forEach ignores returned promises, so everything after it runs before the async work finishes. Use for...of with await or Promise.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/catch will catch this async error." Only for awaited promises and synchronous code. Errors in bare callbacks arrive as the error argument, and an error thrown inside a callback escapes the surrounding try/catch entirely.
"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.
"More npm install is progress." Every dependency is attack surface, bundle weight, and a maintenance liability. Modern Node has native fetch, 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 is package.json? The manifest of a Node project: its name, version, scripts, and dependencies. package-lock.json records the exact resolved versions so installs are reproducible.
What is the difference between dependencies and devDependencies? dependencies are needed to run the app in production; devDependencies are 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 between process.nextTick and setImmediate? nextTick callbacks run before the event loop continues, ahead of promises and all phases. setImmediate callbacks run in the Check phase of the loop. nextTick is higher priority and can starve the loop if abused; setImmediate yields between iterations.
What is middleware? A function in a request-handling chain that receives the request, response, and next, and can read or modify them, end the response, or pass control onward by calling next().
What is the difference between spawn and exec in child_process? spawn streams output and is suited to long-running processes or large output; exec buffers 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 (use worker_threads), prefer async I/O over Sync methods, 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:

js
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:

js
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):

js
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:

js
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:

js
function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

Tricky Gotchas Interviewers Love

The output-order puzzle. Given console.log, setTimeout(.., 0), Promise.resolve().then, and process.nextTick interleaved, the order is: all synchronous logs first, then all nextTick callbacks, then all promise callbacks, then the timeout. Practice tracing these out loud.
setTimeout vs setImmediate at the top level is non-deterministic, but inside an I/O callback setImmediate always runs first. Knowing why (Poll phase is immediately followed by Check) is the real test.
this inside callbacks. A regular function passed as a callback loses the surrounding this. Arrow functions inherit it. Mixing these up causes Cannot read property of undefined errors in class methods used as callbacks.
Floating-point and Buffer sizing. 0.1 + 0.2 !== 0.3 in JavaScript (and therefore Node), and allocating Buffer sizes from untrusted numbers can be a denial-of-service vector. Validate sizes.
Module caching. require caches a module after first load, so a module's top-level code runs only once and every require of it gets the same instance. People are surprised when shared mutable state in a module persists across imports. This is by design.
The __dirname ESM gap. It does not exist in ES modules. Reconstruct it from import.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.
Unhandled error events crash the process. An EventEmitter (including streams) that emits error with no listener throws fatally. Always handle error events.
N

Naga Sai Rao

Some things fade fast. Some last. Learn the ones that last.

About the author →

Related