1. Node.js Core
The fundamentals every Node interview opens with.
1. What Is Node.js?
A runtime, not a language and not a framework. It bundles:
- V8 — Google's JavaScript engine, compiles JS to machine code
- libuv — C library providing the event loop and async I/O
- Node APIs —
fs,http,crypto,path,stream, etc.
Node vs Browser JavaScript
| Browser | Node | |
|---|---|---|
| Global object | window | global / globalThis |
| DOM | Yes | No |
| File system | No | Yes |
| Modules | ESM | CommonJS + ESM |
require | No | Yes (CJS) |
| APIs | fetch, localStorage | fs, process, Buffer |
2. The Event Loop (Most Asked Question)
Node is single-threaded for your JavaScript, but I/O is not.
The Flow
- Your code runs on the single main thread
- When it hits async I/O, Node hands it to libuv — which uses OS async APIs, or a thread pool for filesystem and DNS work
- Your code continues; it does not wait
- When the I/O finishes, its callback is queued
- The event loop picks queued callbacks and runs them on the main thread
The Phases
The loop runs through these in order, repeatedly:
| Phase | Handles |
|---|---|
| timers | setTimeout, setInterval callbacks |
| pending callbacks | Deferred system callbacks (some TCP errors) |
| idle / prepare | Internal |
| poll | Retrieve new I/O events; execute I/O callbacks |
| check | setImmediate callbacks |
| close callbacks | socket.on("close") |
Microtasks Run Between Every Phase
process.nextTick and promise callbacks are not phases. They drain after each phase and after each callback:
process.nextTickqueue drains first- then the promise microtask queue
console.log("1");
setTimeout(() => console.log("2"), 0);
setImmediate(() => console.log("3"));
Promise.resolve().then(() => console.log("4"));
process.nextTick(() => console.log("5"));
console.log("6");Output:
1
6
5 ← nextTick — highest priority microtask
4 ← promise microtask
2 ← timers phase
3 ← check phaseInterview Point
setTimeout(fn, 0) vs setImmediate(fn) at the top level is non-deterministic — it depends on how long the process took to start. Inside an I/O callback, setImmediate always wins, because the loop is already past timers and hits check next. Knowing that distinction is a strong signal.
3. Blocking the Event Loop
The single biggest Node production mistake.
// ❌ Blocks EVERY request for the whole loop
app.get("/hash", (req, res) => {
const hash = crypto.pbkdf2Sync(password, salt, 1_000_000, 64, "sha512");
res.json({ hash });
});
// ✅ Async version — work happens on the thread pool
app.get("/hash", (req, res) => {
crypto.pbkdf2(password, salt, 1_000_000, 64, "sha512", (err, hash) => {
if (err) return res.status(500).json({ error: "Failed" });
res.json({ hash: hash.toString("hex") });
});
});Common Blockers
fs.readFileSync,crypto.*Sync,zlib.*SyncJSON.parseon a very large payload- A tight loop over a huge array
- Catastrophic regex backtracking (ReDoS — a real attack vector)
Fixes
| Situation | Fix |
|---|---|
| CPU work in a request | worker_threads |
| Heavy background job | A queue (BullMQ) plus a separate worker process |
| Multi-core utilisation | cluster or PM2, one process per core |
| Long computation | Chunk it with setImmediate between chunks |
import { Worker } from "node:worker_threads";
app.get("/report", (req, res) => {
const worker = new Worker("./report-worker.js", { workerData: req.query });
worker.on("message", (data) => res.json(data));
worker.on("error", () => res.status(500).json({ error: "Failed" }));
});4. Modules — CommonJS vs ESM
CommonJS (the old default)
const express = require("express");
module.exports = { getUser };- Synchronous loading
requirecan be called conditionally, anywhere__dirnameand__filenameavailable
ES Modules (the standard)
import express from "express";
export { getUser };
export default router;Enable with "type": "module" in package.json, or a .mjs extension.
- Asynchronous, statically analysable — enables tree shaking
- Imports are hoisted; must be top level (use
await import()for dynamic) - No
__dirname:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));Comparison
| CommonJS | ESM | |
|---|---|---|
| Syntax | require / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| Tree shaking | No | Yes |
Top-level await | No | Yes |
| Can import the other | Yes (ESM can import CJS) | CJS cannot require ESM directly |
| File extension in imports | Optional | Required |
Interview Point
The direction is one-way: ESM can import CommonJS, but CommonJS cannot require an ESM module (only await import()). This is why migrating a large Node codebase to ESM is painful and why plenty of production code is still CommonJS.
5. Streams
Process data in chunks instead of loading it all into memory.
// ❌ Loads a 2 GB file entirely into RAM
const data = await fs.promises.readFile("huge.csv");
res.send(data);
// ✅ Constant memory regardless of file size
fs.createReadStream("huge.csv").pipe(res);The Four Types
| Type | Example |
|---|---|
| Readable | fs.createReadStream, req |
| Writable | fs.createWriteStream, res |
| Duplex | TCP socket |
| Transform | zlib.createGzip, a CSV parser |
Piping With Error Handling
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(
fs.createReadStream("input.txt"),
createGzip(),
fs.createWriteStream("output.txt.gz")
);pipeline propagates errors and cleans up every stream. Plain .pipe() chains leak file descriptors on error — knowing that is a strong answer.
Backpressure
If the consumer is slower than the producer, data queues in memory. .pipe() and pipeline handle backpressure automatically by pausing the source. Manual .on("data") handlers do not — that is how you get memory blowups.
6. Buffers
Fixed-length binary data — Node's answer to raw bytes.
const buf = Buffer.from("Hello", "utf8");
buf.toString("base64"); // "SGVsbG8="
buf.length; // 5 bytes, not charactersUsed for file I/O, network packets, crypto and image data.
Never use new Buffer() — deprecated and unsafe. Use Buffer.from() or Buffer.alloc(). Buffer.allocUnsafe() is faster but returns uninitialised memory that may contain old data.
7. process and Environment
process.env.NODE_ENV;
process.argv; // CLI arguments
process.cwd(); // working directory
process.memoryUsage();
process.exit(1); // avoid — let the process end naturally
process.on("uncaughtException", (err) => {
logger.fatal(err);
process.exit(1); // MUST exit — state is unreliable
});
process.on("unhandledRejection", (reason) => {
logger.error(reason);
process.exit(1);
});Interview Point
After an uncaughtException the process is in an unknown state — you cannot safely continue. Log it and exit; let your process manager restart you. Swallowing it and carrying on is a classic bad answer.
In Node 15+, an unhandled promise rejection terminates the process by default, which was the right change.
Graceful Shutdown
const server = app.listen(3000);
const shutdown = async (signal) => {
console.log(`${signal} received, shutting down`);
server.close(async () => { // stop accepting new connections
await db.disconnect(); // finish in-flight work
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref(); // force after 10s
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));Required for zero-downtime deploys — without it, a rolling restart drops in-flight requests. Docker and Kubernetes send SIGTERM before SIGKILL.
8. Cluster and Scaling
Node uses one CPU core. cluster forks one process per core sharing a port.
import cluster from "node:cluster";
import os from "node:os";
if (cluster.isPrimary) {
for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
cluster.on("exit", (worker) => {
console.log(`Worker ${worker.process.pid} died, restarting`);
cluster.fork();
});
} else {
app.listen(3000);
}In practice most teams use PM2 or let the container orchestrator run N replicas instead.
Interview Point
Clustering means your app must be stateless. No in-memory sessions, no in-memory cache assumed shared, no in-memory rate-limit counters — each worker has its own. Move that state to Redis. This is the follow-up interviewers are fishing for.
9. node: Built-in Modules
| Module | Use |
|---|---|
fs / fs/promises | Filesystem |
path | Cross-platform paths — always use it, never string concatenation |
http / https | Servers and clients |
crypto | Hashing, encryption, random values |
stream | Chunked data |
events | EventEmitter |
os | CPU count, memory, platform |
url | Parsing URLs |
worker_threads | CPU-bound parallelism |
child_process | Run external commands |
Prefix with node: — import fs from "node:fs" — so it's unambiguous and cannot be shadowed by a malicious package of the same name.
EventEmitter
import { EventEmitter } from "node:events";
class OrderService extends EventEmitter {
async create(data) {
const order = await db.order.create(data);
this.emit("order:created", order);
return order;
}
}
orders.on("order:created", (order) => sendConfirmationEmail(order));Add emitter.setMaxListeners(n) or you'll see a memory-leak warning after 10 listeners on one event — deliberately, to catch accidental listener accumulation.
10. async/await Patterns
// Sequential — 3× the latency
const user = await getUser(id);
const posts = await getPosts(id);
const comments = await getComments(id);
// Parallel — as slow as the slowest
const [user, posts, comments] = await Promise.all([
getUser(id),
getPosts(id),
getComments(id),
]);Promise.all vs allSettled vs race vs any
| Resolves when | Rejects when | |
|---|---|---|
all | All succeed | Any rejects — you lose the other results |
allSettled | All settle | Never |
race | First settles | First settles with a rejection |
any | First succeeds | All reject |
// One failing service shouldn't kill the whole dashboard
const results = await Promise.allSettled([getStats(), getFeed(), getAlerts()]);
const stats = results[0].status === "fulfilled" ? results[0].value : null;Sequential Loop Trap
// ❌ .forEach does not await — everything fires at once, errors vanish
users.forEach(async (u) => await sendEmail(u));
// ✅ Sequential
for (const u of users) await sendEmail(u);
// ✅ Parallel
await Promise.all(users.map((u) => sendEmail(u)));
// ✅ Parallel with a concurrency limit — the production answer
import pLimit from "p-limit";
const limit = pLimit(5);
await Promise.all(users.map((u) => limit(() => sendEmail(u))));Say the last one. Unbounded Promise.all over 10,000 items opens 10,000 connections and takes down your database. The concurrency limit is what separates a working answer from a production answer.
11. Timeouts and Cancellation
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
return await res.json();
} catch (err) {
if (err.name === "AbortError") throw new Error("Upstream timed out");
throw err;
} finally {
clearTimeout(timeout);
}An outbound call with no timeout is an availability bug — a slow dependency holds your connections open until you run out.
AbortSignal.timeout(5000) is a shorter built-in equivalent in Node 18+.